Skip to main content

khive_runtime/
curation.rs

1// Licensed under the Apache License, Version 2.0.
2
3// FILE SIZE JUSTIFICATION: curation.rs holds entity/note/edge patch types alongside
4// their update and merge implementations. The implementations share private helpers
5// (merge_properties, namespace checks, dedup policy) that need pub(crate) access to
6// runtime internals. Inline tests cover merge semantics that require direct access to
7// those helpers. Split plan: extract patch types into `curation/patch.rs` and merge
8// logic into `curation/merge.rs` once the dedup policy API stabilises.
9//! Curation operations: entity update/merge and edge-list filter type.
10
11use std::any::Any;
12use std::collections::{HashMap, HashSet, VecDeque};
13
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16use uuid::Uuid;
17
18use khive_db::{pool::RuntimeWriteOperation, SqliteError};
19use khive_storage::note::{FilterOp, Note, NoteFilter, PropertyFilter};
20use khive_storage::types::{EdgeFilter, PageRequest, SqlValue, TextDocument};
21use khive_storage::{AtomicUnitOp, EdgeRelation, Entity, SqlStatement, SubstrateKind};
22use khive_types::{Details, EdgeEndpointRule, EventKind, KhiveError};
23use rusqlite::OptionalExtension;
24
25use crate::error::{RuntimeError, RuntimeResult};
26use crate::event_store_guard::EventAttribution;
27use crate::operations::{base_entity_rule_allows, canonical_edge_endpoints, endpoint_matches};
28use crate::runtime::{KhiveRuntime, NamespaceToken};
29
30/// Test-only pause point at the read/write boundary of a guarded
31/// read-modify-write, so a race between two concurrent callers of the same
32/// PRODUCTION entry point (not the underlying store primitive) can be
33/// reproduced deterministically instead of relying on scheduler luck or
34/// sleeps. A no-op unless the calling task runs inside
35/// `AFTER_READ_BARRIER.scope(...)`; production code never establishes that
36/// scope, so `pause_after_read` costs nothing outside these regression
37/// tests, and it does not exist at all in non-test builds.
38#[cfg(test)]
39pub(crate) mod race_seam {
40    use std::sync::Arc;
41    use tokio::sync::Barrier;
42
43    tokio::task_local! {
44        pub(crate) static AFTER_READ_BARRIER: Arc<Barrier>;
45        pub(crate) static BEFORE_ENTITY_INDEX_PUBLISH: Arc<(Barrier, Barrier)>;
46        pub(crate) static BEFORE_ENTITY_VECTOR_PUBLISH: Arc<(Barrier, Barrier)>;
47    }
48
49    pub(crate) async fn pause_after_read() {
50        if let Ok(barrier) = AFTER_READ_BARRIER.try_with(Arc::clone) {
51            barrier.wait().await;
52        }
53    }
54
55    pub(crate) async fn pause_before_entity_index_publish() {
56        if let Ok(barriers) = BEFORE_ENTITY_INDEX_PUBLISH.try_with(Arc::clone) {
57            barriers.0.wait().await;
58            barriers.1.wait().await;
59        }
60    }
61
62    pub(crate) async fn pause_before_entity_vector_publish() {
63        if let Ok(barriers) = BEFORE_ENTITY_VECTOR_PUBLISH.try_with(Arc::clone) {
64            barriers.0.wait().await;
65            barriers.1.wait().await;
66        }
67    }
68}
69
70pub(crate) fn stale_note_snapshot_error(id: Uuid) -> RuntimeError {
71    RuntimeError::Khive(KhiveError::conflict(format!(
72        "note {id} changed concurrently after it was read; retry with fresh state"
73    )))
74}
75
76pub(crate) fn stale_entity_snapshot_error(id: Uuid) -> RuntimeError {
77    RuntimeError::Khive(KhiveError::conflict(format!(
78        "entity {id} changed concurrently after it was read; retry with fresh state"
79    )))
80}
81
82pub(crate) fn stale_edge_snapshot_error(id: Uuid) -> RuntimeError {
83    RuntimeError::Khive(KhiveError::conflict(format!(
84        "edge {id} changed concurrently after it was read; retry with fresh state"
85    )))
86}
87
88/// Immutable embedding-registry view for one logical write.
89///
90/// Document byte budgets are derived from the model name at the embedding seam,
91/// so retaining the exact name set keeps merge cleanup, table preparation, and
92/// survivor reindexing on one plan during concurrent registration.
93#[derive(Clone, Debug, Default)]
94struct EmbeddingModelPlan {
95    model_names: Vec<String>,
96}
97
98impl EmbeddingModelPlan {
99    fn capture(runtime: &KhiveRuntime) -> Self {
100        Self {
101            model_names: runtime.registered_embedding_model_names(),
102        }
103    }
104
105    fn is_empty(&self) -> bool {
106        self.model_names.is_empty()
107    }
108
109    fn model_names(&self) -> &[String] {
110        &self.model_names
111    }
112
113    fn vector_tables(&self) -> Vec<String> {
114        self.model_names
115            .iter()
116            .map(|name| format!("vec_{}", crate::config::sanitize_key(name)))
117            .collect()
118    }
119}
120
121// ---------------------------------------------------------------------------
122// Public types
123// ---------------------------------------------------------------------------
124
125/// Patch for `update_entity`. Only `Some(_)` fields are applied; `None` means "leave unchanged".
126///
127/// For `description`:
128/// - `None` (outer) — leave the current description as-is
129/// - `Some(None)` — clear the description (set to NULL)
130/// - `Some(Some(s))` — set the description to `s`
131///
132/// For `properties` (deep-merge semantics):
133/// - `None` — leave properties as-is
134/// - `Some(value)` — deep-merge `value` into existing properties. Keys present in
135///   the patch overwrite existing keys; keys absent from the patch are preserved.
136///   Removing a key requires explicit replacement of the parent object (or a future
137///   `unset`/`null-marker` extension).
138///
139/// For `tags` — replace semantics: `Some(vec)` sets tags to exactly `vec`. To add
140/// a tag without losing existing tags, read the entity first, push the new tag,
141/// and pass the full list back.
142///
143/// For `entity_type` — ADR-014 tri-state: `None` leaves the current type
144/// unchanged, `Some(None)` explicitly clears it, and `Some(Some(value))`
145/// validates and normalizes `value` through the installed entity-type
146/// registry.
147#[derive(Clone, Debug, Default)]
148pub struct EntityPatch {
149    pub name: Option<String>,
150    pub description: Option<Option<String>>,
151    pub properties: Option<Value>,
152    pub tags: Option<Vec<String>>,
153    pub entity_type: Option<Option<String>>,
154}
155
156/// Policy used when deduplicating two entities.
157#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
158#[serde(rename_all = "snake_case")]
159pub enum EntityDedupMergePolicy {
160    /// `into` values win on conflict. Tags are unioned. Properties from `from` fill in
161    /// keys that `into` doesn't have. This is the default.
162    #[default]
163    PreferInto,
164    /// `from` values win on conflict.
165    PreferFrom,
166    /// Deep-merge: object properties merge recursively. Scalar conflicts go to `into`.
167    Union,
168}
169
170/// Safety-floor guard that refused an explicit entity merge.
171#[derive(Clone, Copy, Debug, PartialEq, Eq)]
172pub enum EntityMergeGuard {
173    EntityKind,
174    NameSimilarity,
175    ProjectCompatibility,
176}
177
178impl EntityMergeGuard {
179    pub fn as_str(self) -> &'static str {
180        match self {
181            Self::EntityKind => "entity_kind",
182            Self::NameSimilarity => "name_similarity",
183            Self::ProjectCompatibility => "project_compatibility",
184        }
185    }
186
187    /// What this guard compared, phrased for the caller that hit it.
188    ///
189    /// The refusal a caller reads has to say what was looked at, because the
190    /// guard name alone ("name_similarity") does not tell a caller which two
191    /// fields it has to change to make the merge acceptable.
192    pub fn compared(self) -> &'static str {
193        match self {
194            Self::EntityKind => "the records' entity kinds",
195            Self::NameSimilarity => "the records' names",
196            Self::ProjectCompatibility => "the project lists in the records' properties",
197        }
198    }
199}
200
201/// Validate the non-forced entity-merge safety floor.
202pub fn validate_entity_merge_floor(into: &Entity, from: &Entity) -> Result<(), EntityMergeGuard> {
203    if into.kind != from.kind {
204        return Err(EntityMergeGuard::EntityKind);
205    }
206    if !names_are_similar(&into.name, &from.name) {
207        return Err(EntityMergeGuard::NameSimilarity);
208    }
209    if projects_are_disjoint(into, from) {
210        return Err(EntityMergeGuard::ProjectCompatibility);
211    }
212    Ok(())
213}
214
215/// The sentence a safety-floor refusal shows its caller.
216///
217/// It names the check, says what that check compared, and gives the caller an
218/// action it can actually take. It deliberately does not name the override
219/// parameter: the override exists for a developer integrating this runtime, and
220/// a consumer principal that is handed the parameter's name spends its next turn
221/// retrying with it instead of looking at the two records.
222pub fn entity_merge_guard_refusal_message(guard: EntityMergeGuard) -> String {
223    format!(
224        "entity merge refused by the {} check, which compared {}; make the records agree on \
225         that check before merging, or ask an operator to authorize an override",
226        guard.as_str(),
227        guard.compared()
228    )
229}
230
231/// The two values `guard` compared, for a caller-facing preview of the refusal.
232///
233/// The project side returns whatever that property holds, or `null` when the
234/// record has none. `projects_are_disjoint` refuses only on two non-empty
235/// arrays, so what a caller is shown here is what the guard read.
236pub fn entity_merge_guard_compared_values(
237    guard: EntityMergeGuard,
238    into: &Entity,
239    from: &Entity,
240) -> (Value, Value) {
241    let projects = |entity: &Entity| {
242        entity
243            .properties
244            .as_ref()
245            .and_then(|properties| properties.get("projects"))
246            .cloned()
247            .unwrap_or(Value::Null)
248    };
249    match guard {
250        EntityMergeGuard::EntityKind => (
251            Value::String(into.kind.clone()),
252            Value::String(from.kind.clone()),
253        ),
254        EntityMergeGuard::NameSimilarity => (
255            Value::String(into.name.clone()),
256            Value::String(from.name.clone()),
257        ),
258        EntityMergeGuard::ProjectCompatibility => (projects(into), projects(from)),
259    }
260}
261
262/// Convert a safety-floor refusal into the merge verb's structured conflict contract.
263pub fn entity_merge_guard_error(guard: EntityMergeGuard) -> RuntimeError {
264    RuntimeError::Khive(
265        KhiveError::conflict(entity_merge_guard_refusal_message(guard)).with_details(Details::new(
266            [("guard", guard.as_str()), ("compared", guard.compared())],
267        )),
268    )
269}
270
271fn names_are_similar(left: &str, right: &str) -> bool {
272    let left = normalize_name(left);
273    let right = normalize_name(right);
274    if left.is_empty() || right.is_empty() {
275        return false;
276    }
277    if left == right {
278        return true;
279    }
280
281    let shorter_len = left.chars().count().min(right.chars().count());
282    if shorter_len >= 3 && (left.starts_with(&right) || right.starts_with(&left)) {
283        return true;
284    }
285
286    let left_trigrams = trigrams(&left);
287    let right_trigrams = trigrams(&right);
288    if left_trigrams.is_empty() || right_trigrams.is_empty() {
289        return false;
290    }
291    let overlap = left_trigrams.intersection(&right_trigrams).count();
292    overlap.saturating_mul(4) >= left_trigrams.len().saturating_add(right_trigrams.len())
293}
294
295fn normalize_name(name: &str) -> String {
296    let mut normalized = String::with_capacity(name.len());
297    let mut pending_space = false;
298    for ch in name.chars().flat_map(char::to_lowercase) {
299        if ch.is_whitespace() {
300            pending_space = !normalized.is_empty();
301        } else {
302            if pending_space {
303                normalized.push(' ');
304                pending_space = false;
305            }
306            normalized.push(ch);
307        }
308    }
309    normalized
310}
311
312fn trigrams(value: &str) -> HashSet<[char; 3]> {
313    let chars: Vec<char> = value.chars().collect();
314    chars
315        .windows(3)
316        .map(|window| [window[0], window[1], window[2]])
317        .collect()
318}
319
320fn projects_are_disjoint(into: &Entity, from: &Entity) -> bool {
321    let Some(into_projects) = into
322        .properties
323        .as_ref()
324        .and_then(|properties| properties.get("projects"))
325        .and_then(Value::as_array)
326    else {
327        return false;
328    };
329    let Some(from_projects) = from
330        .properties
331        .as_ref()
332        .and_then(|properties| properties.get("projects"))
333        .and_then(Value::as_array)
334    else {
335        return false;
336    };
337    if into_projects.is_empty() || from_projects.is_empty() {
338        return false;
339    }
340
341    let (indexed, candidates) = if into_projects.len() <= from_projects.len() {
342        (into_projects, from_projects)
343    } else {
344        (from_projects, into_projects)
345    };
346    let mut indexed_strings = HashSet::new();
347    let mut indexed_values = HashSet::new();
348    for value in indexed {
349        if let Some(value) = value.as_str() {
350            indexed_strings.insert(normalize_project_string(value));
351        } else {
352            indexed_values.insert(value.clone());
353        }
354    }
355    !candidates.iter().any(|candidate| {
356        if let Some(candidate) = candidate.as_str() {
357            indexed_strings.contains(&normalize_project_string(candidate))
358        } else {
359            indexed_values.contains(candidate)
360        }
361    })
362}
363
364fn normalize_project_string(value: &str) -> String {
365    value.trim().to_ascii_lowercase()
366}
367
368/// Strategy for merging note content when two notes are combined.
369#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
370#[serde(rename_all = "snake_case")]
371pub enum ContentMergeStrategy {
372    #[default]
373    Append,
374    PreferInto,
375    PreferFrom,
376}
377
378/// Result returned by `merge_entity` / `merge_note`.
379#[derive(Clone, Debug, Serialize, Deserialize)]
380pub struct MergeSummary {
381    pub kept_id: Uuid,
382    pub removed_id: Uuid,
383    pub edges_rewired: usize,
384    /// Edges dropped because both rewired endpoints resolved to the
385    /// surviving record — the edge described a relationship *between* the
386    /// two merge operands (e.g. `supports`/`refutes`), and once merged that
387    /// relationship has no referent to point at, so it is deleted rather
388    /// than kept as a self-referencing row. Distinct from
389    /// `edges_contract_skipped` (an endpoint-contract rejection) and
390    /// `edge_conflict_preimages` (a natural-key collision with an unrelated
391    /// existing edge) — a self-loop has no such competitor.
392    #[serde(default)]
393    pub edges_self_loop_dropped: usize,
394    /// Full preimages for the edges counted in `edges_self_loop_dropped`, in
395    /// the same [`MergeEdgePreimage`] shape `edge_conflict_preimages` uses,
396    /// so a dropped relationship between the merge operands is recoverable
397    /// rather than silently destroyed by the row delete.
398    #[serde(default)]
399    pub self_loop_edge_preimages: Vec<MergeEdgePreimage>,
400    /// Incident edges dropped instead of rewired because the rewired
401    /// `(source, relation, target)` triple would violate the pack endpoint
402    /// contract `link` enforces (khive#1216) — consistent with the existing
403    /// dangling-endpoint skip behavior, never silently rewired into a
404    /// contract-violating edge.
405    #[serde(default)]
406    pub edges_contract_skipped: usize,
407    /// Full preimages for natural-key edge conflicts resolved by this merge.
408    /// Each entry names the surviving row, the dropped duplicate, and every
409    /// incident edge cascaded with it so the destructive step is reversible.
410    #[serde(default)]
411    pub edge_conflict_preimages: Vec<MergeEdgeConflictPreimage>,
412    pub properties_merged: usize,
413    pub tags_unioned: usize,
414    pub content_appended: bool,
415    pub dry_run: bool,
416    /// Rows and bytes this merge materialized against the per-transaction
417    /// budget, alongside the limits it was admitted under. Enforcement already
418    /// happened inside the transaction; this is the observed usage.
419    #[serde(default)]
420    pub tx_budget: MergeTxBudgetReport,
421    /// Actual embedding-input truncation observed while reindexing the survivor.
422    #[serde(skip)]
423    pub embedding_truncation: crate::retrieval::EmbeddingTruncationReport,
424    /// Error returned by the post-commit survivor reindex. A set value means
425    /// the note merge committed but the reindex did not.
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    pub post_commit_reindex_error: Option<String>,
428}
429
430/// Complete stored state of an edge removed while resolving a merge conflict.
431///
432/// Timestamps use the storage layer's microsecond representation. `relation`
433/// remains a string so a legacy row predating the closed relation enum can
434/// still be captured without making an otherwise valid merge fail.
435#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
436pub struct MergeEdgePreimage {
437    pub id: Uuid,
438    pub namespace: String,
439    pub source_id: Uuid,
440    pub target_id: Uuid,
441    pub relation: String,
442    pub weight: f64,
443    pub created_at: i64,
444    pub updated_at: i64,
445    pub deleted_at: Option<i64>,
446    pub target_backend: Option<String>,
447    pub metadata: Option<Value>,
448}
449
450/// One natural-key collision resolved by a direct entity or note merge.
451#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
452pub struct MergeEdgeConflictPreimage {
453    pub surviving_edge_id: Uuid,
454    pub dropped_edge: MergeEdgePreimage,
455    /// Edges removed by the hard-delete cascade because they referenced the
456    /// dropped edge as a node. Under the accepted endpoint contract these are
457    /// `annotates` edges, including already-soft-deleted rows.
458    #[serde(default, skip_serializing_if = "Vec::is_empty")]
459    pub incident_edge_preimages: Vec<MergeEdgePreimage>,
460}
461
462/// Default per-transaction row cap for a direct entity/note merge. Every row
463/// materialized into Rust inside the merge transaction counts: the two merge
464/// records, incident edges, endpoint-contract resolutions, and conflict
465/// cascade rows. Far above any legitimate single-record merge, while bounding
466/// the writer hold and heap of a hub-node merge (`traverse` bounds its shared
467/// read expansion at 100k rows; a merge holds the writer, so it is tighter).
468const MERGE_TX_MAX_ROWS: usize = 50_000;
469
470/// Default per-transaction aggregate byte cap across the same materialized
471/// state (variable-length payloads: descriptions/content, properties, tags,
472/// edge metadata, fanout table names).
473const MERGE_TX_MAX_BYTES: usize = 32 * 1024 * 1024;
474
475/// Hard materialization limits for one merge transaction.
476///
477/// Enforced on the writer connection inside the merge's own `BEGIN IMMEDIATE`
478/// transaction, so the counted rows are exactly the rows the merge operates
479/// on — a pre-flight count on another connection could be outgrown between
480/// the count and the merge. Exceeding either limit rejects the merge with the
481/// observed counts before further state is materialized, and the transaction
482/// rolls back. Dry runs are budgeted identically: the preview performs the
483/// same reads and carries the same materialization hazard.
484#[derive(Clone, Copy, Debug)]
485pub struct MergeTxLimits {
486    pub max_rows: usize,
487    pub max_bytes: usize,
488}
489
490impl Default for MergeTxLimits {
491    fn default() -> Self {
492        Self {
493            max_rows: MERGE_TX_MAX_ROWS,
494            max_bytes: MERGE_TX_MAX_BYTES,
495        }
496    }
497}
498
499/// Observed budget usage for one merge transaction (see [`MergeTxLimits`]).
500#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
501pub struct MergeTxBudgetReport {
502    pub rows_charged: usize,
503    pub bytes_charged: usize,
504    pub max_rows: usize,
505    pub max_bytes: usize,
506}
507
508/// Running row/byte account for one merge transaction.
509struct MergeTxBudget {
510    limits: MergeTxLimits,
511    rows: usize,
512    bytes: usize,
513}
514
515impl MergeTxBudget {
516    fn new(limits: MergeTxLimits) -> Self {
517        Self {
518            limits,
519            rows: 0,
520            bytes: 0,
521        }
522    }
523
524    /// Add `rows`/`bytes` to the account; reject once either limit is passed.
525    /// Callers charge each unit of state *before* retaining it, so a rejected
526    /// merge never materializes more than one row past the cap.
527    fn charge(&mut self, rows: usize, bytes: usize, context: &str) -> Result<(), SqliteError> {
528        self.rows = self.rows.saturating_add(rows);
529        self.bytes = self.bytes.saturating_add(bytes);
530        if self.rows > self.limits.max_rows || self.bytes > self.limits.max_bytes {
531            return Err(SqliteError::InvalidData(format!(
532                "merge transaction budget exceeded while {context}: {} rows / {} bytes \
533                 materialized (limits {} rows / {} bytes); the merge was rejected before \
534                 materializing further state — curate the incident edges down or merge in \
535                 smaller steps",
536                self.rows, self.bytes, self.limits.max_rows, self.limits.max_bytes
537            )));
538        }
539        Ok(())
540    }
541
542    fn report(&self) -> MergeTxBudgetReport {
543        MergeTxBudgetReport {
544            rows_charged: self.rows,
545            bytes_charged: self.bytes,
546            max_rows: self.limits.max_rows,
547            max_bytes: self.limits.max_bytes,
548        }
549    }
550}
551
552/// Fixed overhead approximates the id/timestamp/weight columns; variable
553/// payloads are counted at their stored length.
554fn edge_row_budget_bytes(edge: &EdgeRow) -> usize {
555    96 + edge.namespace.len()
556        + edge.relation.len()
557        + edge.target_backend.as_deref().map_or(0, str::len)
558        + edge.metadata.as_deref().map_or(0, str::len)
559}
560
561/// Patch for `update_edge`. Only `Some(_)` fields are applied; `None` means "leave unchanged".
562///
563/// For `properties` — replacement semantics (not deep merge): `Some(value)` replaces
564/// the entire metadata object. `None` leaves metadata unchanged.
565#[derive(Clone, Debug, Default)]
566pub struct EdgePatch {
567    pub relation: Option<EdgeRelation>,
568    pub weight: Option<f64>,
569    pub properties: Option<Value>,
570}
571
572/// Kind-owned property semantics carried from validated hook preparation to
573/// the shared note merge. The default preserves ordinary JSON merge behavior.
574#[derive(Clone, Debug, Default)]
575pub struct NoteUpdatePolicy {
576    kind: Option<String>,
577    null_clearing_properties: &'static [&'static str],
578}
579
580impl NoteUpdatePolicy {
581    pub(crate) fn for_kind(kind: &str, null_clearing_properties: &'static [&'static str]) -> Self {
582        Self {
583            kind: Some(kind.to_owned()),
584            null_clearing_properties,
585        }
586    }
587}
588
589/// Patch for `update_note`. Only `Some(_)` fields are applied; `None` means "leave unchanged".
590///
591/// For `salience`/`decay_factor`:
592/// - `None` (outer) — leave unchanged
593/// - `Some(None)` — clear the value
594/// - `Some(Some(v))` — set to v
595#[derive(Clone, Debug, Default)]
596pub struct NotePatch {
597    pub name: Option<Option<String>>,
598    pub content: Option<String>,
599    pub salience: Option<Option<f64>>,
600    pub decay_factor: Option<Option<f64>>,
601    pub properties: Option<Value>,
602    pub(crate) kind_status: Option<String>,
603    pub write_options: crate::note_write::NoteWriteOptions,
604    pub(crate) update_policy: NoteUpdatePolicy,
605}
606
607/// Normalize the public note tag replacement into its stored property before
608/// kind hooks inspect the patch. An explicit list, including an empty one,
609/// wins over properties.tags; omission and null preserve the property patch.
610pub(crate) fn normalize_note_update_tags(args: &mut Value) -> RuntimeResult<()> {
611    let args = args
612        .as_object_mut()
613        .ok_or_else(|| RuntimeError::InvalidInput("update arguments must be an object".into()))?;
614    let Some(tags) = args.get("tags").filter(|value| !value.is_null()) else {
615        return Ok(());
616    };
617    let tags: Vec<String> = serde_json::from_value(tags.clone()).map_err(|error| {
618        RuntimeError::InvalidInput(format!("tags must be an array of strings: {error}"))
619    })?;
620    let mut properties = match args.get("properties") {
621        None | Some(Value::Null) => serde_json::Map::new(),
622        Some(Value::Object(properties)) => properties.clone(),
623        Some(_) => {
624            return Err(RuntimeError::InvalidInput(
625                "properties must be an object".into(),
626            ));
627        }
628    };
629    properties.insert("tags".into(), serde_json::json!(tags));
630    args.insert("properties".into(), Value::Object(properties));
631    // A hook may normalize this property further. Remove the alias so later
632    // preparation cannot overwrite the hook's result by applying it again.
633    args.remove("tags");
634    Ok(())
635}
636
637impl NotePatch {
638    /// Construct a `NotePatch` from the public fields only.
639    /// Use this from external crates; `kind_status` is set to `None`.
640    pub fn new(
641        name: Option<Option<String>>,
642        content: Option<String>,
643        salience: Option<Option<f64>>,
644        decay_factor: Option<Option<f64>>,
645        properties: Option<Value>,
646    ) -> Self {
647        Self {
648            name,
649            content,
650            salience,
651            decay_factor,
652            properties,
653            kind_status: None,
654            write_options: Default::default(),
655            update_policy: Default::default(),
656        }
657    }
658
659    pub fn with_write_options(mut self, options: crate::note_write::NoteWriteOptions) -> Self {
660        self.write_options = options;
661        self
662    }
663
664    /// Apply the owning kind's policy returned by validated hook preparation.
665    /// This is not a caller-facing property-deletion parameter.
666    pub fn with_update_policy(mut self, policy: NoteUpdatePolicy) -> Self {
667        self.update_policy = policy;
668        self
669    }
670}
671
672/// Filter for `list_edges` / `count_edges`.
673#[derive(Clone, Debug, Default)]
674pub struct EdgeListFilter {
675    pub source_id: Option<Uuid>,
676    pub target_id: Option<Uuid>,
677    /// Empty = any relation.
678    pub relations: Vec<EdgeRelation>,
679    pub min_weight: Option<f64>,
680    pub max_weight: Option<f64>,
681}
682
683impl From<EdgeListFilter> for EdgeFilter {
684    fn from(f: EdgeListFilter) -> Self {
685        EdgeFilter {
686            source_ids: f.source_id.into_iter().collect(),
687            target_ids: f.target_id.into_iter().collect(),
688            relations: f.relations,
689            min_weight: f.min_weight,
690            max_weight: f.max_weight,
691            ..Default::default()
692        }
693    }
694}
695
696// ---------------------------------------------------------------------------
697// Private types
698// ---------------------------------------------------------------------------
699
700#[derive(Clone, Copy, Debug, PartialEq, Eq)]
701enum EntityMergeValidation {
702    LegacyKind,
703    SafetyFloor,
704    Forced,
705}
706
707#[derive(Debug)]
708enum EntityMergeRefusal {
709    LegacyKind {
710        into_id: Uuid,
711        into_kind: String,
712        from_id: Uuid,
713        from_kind: String,
714    },
715    SafetyFloor(EntityMergeGuard),
716}
717
718impl EntityMergeRefusal {
719    fn into_runtime_error(self) -> RuntimeError {
720        match self {
721            Self::LegacyKind {
722                into_id,
723                into_kind,
724                from_id,
725                from_kind,
726            } => RuntimeError::InvalidInput(format!(
727                "cannot merge entities of different kinds: into={into_id} ({into_kind}), \
728                 from={from_id} ({from_kind}); merge requires both entities to share the same kind"
729            )),
730            Self::SafetyFloor(guard) => entity_merge_guard_error(guard),
731        }
732    }
733}
734
735#[derive(Debug)]
736enum MergeSqlError {
737    Sqlite(SqliteError),
738    Refusal(RuntimeError),
739}
740
741impl std::fmt::Display for MergeSqlError {
742    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
743        match self {
744            Self::Sqlite(error) => std::fmt::Display::fmt(error, f),
745            Self::Refusal(_) => f.write_str("merge refused by transactional policy"),
746        }
747    }
748}
749
750impl std::error::Error for MergeSqlError {}
751
752impl From<SqliteError> for MergeSqlError {
753    fn from(error: SqliteError) -> Self {
754        Self::Sqlite(error)
755    }
756}
757
758impl From<rusqlite::Error> for MergeSqlError {
759    fn from(error: rusqlite::Error) -> Self {
760        Self::Sqlite(SqliteError::Rusqlite(error))
761    }
762}
763
764/// Event data captured at the authorized runtime boundary before the merge
765/// moves to a writer thread. The event itself is built from the transaction's
766/// summary so destructive edge preimages are inserted before commit.
767struct MergeEventContext {
768    attribution: EventAttribution,
769    reason: Option<String>,
770    force: bool,
771    strategy: EntityDedupMergePolicy,
772    content_strategy: ContentMergeStrategy,
773    kind: EventKind,
774    substrate: SubstrateKind,
775    event_id: Option<Uuid>,
776}
777
778fn append_merge_event_in_transaction(
779    conn: &rusqlite::Connection,
780    context: MergeEventContext,
781    summary: &MergeSummary,
782    namespace: &str,
783) -> Result<(), MergeSqlError> {
784    let policy = match context.strategy {
785        EntityDedupMergePolicy::PreferInto => "prefer_into",
786        EntityDedupMergePolicy::PreferFrom => "prefer_from",
787        EntityDedupMergePolicy::Union => "union",
788    };
789    let mut payload = serde_json::json!({
790        "into_id": summary.kept_id,
791        "from_id": summary.removed_id,
792        "policy": policy,
793        "content_strategy": format!("{:?}", context.content_strategy),
794        "edges_rewired": summary.edges_rewired,
795        "edges_self_loop_dropped": summary.edges_self_loop_dropped,
796        "self_loop_edge_preimages": &summary.self_loop_edge_preimages,
797        "edges_contract_skipped": summary.edges_contract_skipped,
798        "edge_conflict_preimages": &summary.edge_conflict_preimages,
799    });
800    if let Some(reason) = context.reason {
801        payload["reason"] = serde_json::Value::String(reason);
802    }
803    if context.force {
804        payload["force"] = serde_json::Value::Bool(true);
805    }
806    let mut event =
807        khive_storage::event::Event::new(namespace, "merge", context.kind, context.substrate, "")
808            .with_target(summary.kept_id)
809            .with_payload(payload);
810    if let Some(event_id) = context.event_id {
811        event.id = event_id;
812    }
813    let event = context.attribution.stamp(event);
814    khive_db::stores::event::append_event_in_transaction(conn, &event)?;
815    Ok(())
816}
817
818/// Recover only our semantic refusal after the writer has confirmed rollback.
819/// Other sources retain the request-state envelope and every driver field.
820fn recover_rolled_back_merge_refusal(
821    error: khive_storage::StorageError,
822) -> Result<RuntimeError, khive_storage::StorageError> {
823    use khive_storage::{StorageError, WriterTaskRequestState};
824
825    let source = match error {
826        StorageError::WriterTaskRequestFailed {
827            request_state: WriterTaskRequestState::TransactionRolledBack,
828            source,
829        } => source,
830        error => return Err(error),
831    };
832    let source = match *source {
833        StorageError::Driver {
834            capability,
835            operation,
836            source,
837        } => match source.downcast::<MergeSqlError>() {
838            Ok(error) => match *error {
839                MergeSqlError::Refusal(error) => return Ok(error),
840                error => StorageError::driver(capability, operation, error),
841            },
842            Err(source) => StorageError::Driver {
843                capability,
844                operation,
845                source,
846            },
847        },
848        error => error,
849    };
850    Err(StorageError::WriterTaskRequestFailed {
851        request_state: WriterTaskRequestState::TransactionRolledBack,
852        source: Box::new(source),
853    })
854}
855
856fn map_merge_entity_storage_error(error: khive_storage::StorageError) -> RuntimeError {
857    let error = match recover_rolled_back_merge_refusal(error) {
858        Ok(refusal) => return refusal,
859        Err(error) => error,
860    };
861    match error {
862        khive_storage::StorageError::Driver {
863            capability,
864            operation,
865            source,
866        } => match source.downcast::<MergeSqlError>() {
867            Ok(error) => match *error {
868                MergeSqlError::Sqlite(error) => RuntimeError::Sqlite(error),
869                MergeSqlError::Refusal(error) => error,
870            },
871            Err(source) => RuntimeError::Storage(khive_storage::StorageError::Driver {
872                capability,
873                operation,
874                source,
875            }),
876        },
877        error => RuntimeError::Storage(error),
878    }
879}
880
881fn map_merge_note_storage_error(error: khive_storage::StorageError) -> RuntimeError {
882    let error = match recover_rolled_back_merge_refusal(error) {
883        Ok(refusal) => return refusal,
884        Err(error) => error,
885    };
886    match error {
887        khive_storage::StorageError::Driver {
888            capability,
889            operation,
890            source,
891        } => match source.downcast::<MergeSqlError>() {
892            Ok(error) => match *error {
893                MergeSqlError::Refusal(error) => error,
894                // Preserve the existing note route's storage error envelope.
895                MergeSqlError::Sqlite(error) => RuntimeError::Storage(
896                    khive_storage::StorageError::driver(capability, operation, error),
897                ),
898            },
899            Err(source) => RuntimeError::Storage(khive_storage::StorageError::Driver {
900                capability,
901                operation,
902                source,
903            }),
904        },
905        error => RuntimeError::Storage(error),
906    }
907}
908
909// REASON: EdgeRow fields are populated via rusqlite row mapping. The struct is fully
910// constructed even when not all fields are read back after construction. The complete
911// field mapping guards against column-order bugs when the schema changes.
912#[derive(Clone)]
913struct EdgeRow {
914    id: Uuid,
915    /// The edge's own attribution namespace (khive#1236) — may differ from the
916    /// merge's target namespace, since by-ID edge endpoints are namespace-agnostic
917    /// (ADR-007 Rev 6) and an edge is stamped with its *creator's* namespace, not
918    /// either endpoint's. All row-scoped SQL against this edge (conflict probe,
919    /// update, delete) must key off this field, never the merge's `namespace` arg.
920    namespace: String,
921    source_id: Uuid,
922    target_id: Uuid,
923    relation: String,
924    weight: f64,
925    created_at: i64,
926    updated_at: i64,
927    deleted_at: Option<i64>,
928    target_backend: Option<String>,
929    metadata: Option<String>,
930}
931
932fn edge_row_preimage(edge: &EdgeRow) -> Result<MergeEdgePreimage, SqliteError> {
933    let metadata = edge
934        .metadata
935        .as_deref()
936        .map(serde_json::from_str)
937        .transpose()
938        .map_err(|error| SqliteError::InvalidData(error.to_string()))?;
939    Ok(MergeEdgePreimage {
940        id: edge.id,
941        namespace: edge.namespace.clone(),
942        source_id: edge.source_id,
943        target_id: edge.target_id,
944        relation: edge.relation.clone(),
945        weight: edge.weight,
946        created_at: edge.created_at,
947        updated_at: edge.updated_at,
948        deleted_at: edge.deleted_at,
949        target_backend: edge.target_backend.clone(),
950        metadata,
951    })
952}
953
954/// Capture every row that the accepted hard-edge-delete cascade would remove
955/// when `root_edge_id` is purged. The traversal is recursive because an
956/// `annotates` edge may itself be an annotation target. Rows that also touch a
957/// merge participant use their transaction-start snapshot from `original_edges`
958/// so the preimage never reflects an earlier rewire in the same merge.
959fn collect_conflict_incident_edge_preimages(
960    conn: &rusqlite::Connection,
961    root_edge_id: Uuid,
962    original_edges: &HashMap<Uuid, EdgeRow>,
963    budget: &mut MergeTxBudget,
964) -> Result<Vec<MergeEdgePreimage>, SqliteError> {
965    let parse_id =
966        |s: String| Uuid::parse_str(&s).map_err(|e| SqliteError::InvalidData(e.to_string()));
967    let mut queue = VecDeque::from([root_edge_id]);
968    let mut seen = HashSet::from([root_edge_id]);
969    let mut preimages = Vec::new();
970
971    while let Some(target_edge_id) = queue.pop_front() {
972        let mut stmt = conn.prepare(
973            "SELECT id, namespace, source_id, target_id, relation, weight, created_at, \
974                    updated_at, deleted_at, target_backend, metadata \
975             FROM graph_edges WHERE source_id = ?1 OR target_id = ?1 ORDER BY id",
976        )?;
977        let mut rows = stmt.query(rusqlite::params![target_edge_id.to_string()])?;
978        while let Some(row) = rows.next()? {
979            let edge = EdgeRow {
980                id: parse_id(row.get(0)?)?,
981                namespace: row.get(1)?,
982                source_id: parse_id(row.get(2)?)?,
983                target_id: parse_id(row.get(3)?)?,
984                relation: row.get(4)?,
985                weight: row.get(5)?,
986                created_at: row.get(6)?,
987                updated_at: row.get(7)?,
988                deleted_at: row.get(8)?,
989                target_backend: row.get(9)?,
990                metadata: row.get(10)?,
991            };
992            budget.charge(
993                1,
994                edge_row_budget_bytes(&edge),
995                "collecting conflict cascade rows",
996            )?;
997            if !seen.insert(edge.id) {
998                continue;
999            }
1000            let preimage = match original_edges.get(&edge.id) {
1001                Some(original) => edge_row_preimage(original)?,
1002                None => edge_row_preimage(&edge)?,
1003            };
1004            queue.push_back(edge.id);
1005            preimages.push(preimage);
1006        }
1007    }
1008
1009    Ok(preimages)
1010}
1011
1012fn delete_conflict_incident_edges(
1013    conn: &rusqlite::Connection,
1014    preimages: &[MergeEdgePreimage],
1015) -> Result<(), SqliteError> {
1016    for edge in preimages.iter().rev() {
1017        conn.execute(
1018            khive_db::stores::graph::EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL,
1019            rusqlite::params![&edge.namespace, edge.id.to_string()],
1020        )?;
1021    }
1022    Ok(())
1023}
1024
1025/// Resolves the substrate (`"entity"` or `"note"`), kind, and entity_type (entities
1026/// only) of an edge endpoint by id, namespace-agnostically — by-ID resolution is
1027/// namespace-agnostic by design (ADR-007 Rev 6), and an edge's non-merging endpoint
1028/// may live in any namespace. Returns `None` if `id` resolves to neither table
1029/// (e.g. a hard-deleted or otherwise absent record); callers must treat that as
1030/// "the endpoint contract cannot be evaluated" and drop the edge rather than
1031/// silently allow it through.
1032/// `(substrate, kind, entity_type)` for a resolved merge-edge endpoint.
1033type MergeEdgeEndpointInfo = (&'static str, String, Option<String>);
1034
1035fn resolve_merge_edge_endpoint(
1036    conn: &rusqlite::Connection,
1037    id: Uuid,
1038) -> Result<Option<MergeEdgeEndpointInfo>, SqliteError> {
1039    let id_str = id.to_string();
1040    if let Some((kind, entity_type)) = conn
1041        .query_row(
1042            "SELECT kind, entity_type FROM entities WHERE id = ?1",
1043            rusqlite::params![&id_str],
1044            |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)),
1045        )
1046        .optional()
1047        .map_err(SqliteError::Rusqlite)?
1048    {
1049        return Ok(Some(("entity", kind, entity_type)));
1050    }
1051    if let Some(kind) = conn
1052        .query_row(
1053            "SELECT kind FROM notes WHERE id = ?1",
1054            rusqlite::params![&id_str],
1055            |row| row.get::<_, String>(0),
1056        )
1057        .optional()
1058        .map_err(SqliteError::Rusqlite)?
1059    {
1060        return Ok(Some(("note", kind, None)));
1061    }
1062    Ok(None)
1063}
1064
1065/// [`resolve_merge_edge_endpoint`] with the resolved row charged against the
1066/// merge transaction budget — endpoint resolution reads one row per non-merging
1067/// endpoint, so a hub merge's contract checks are part of its materialization.
1068fn resolve_merge_edge_endpoint_budgeted(
1069    conn: &rusqlite::Connection,
1070    id: Uuid,
1071    budget: &mut MergeTxBudget,
1072) -> Result<Option<MergeEdgeEndpointInfo>, SqliteError> {
1073    let info = resolve_merge_edge_endpoint(conn, id)?;
1074    if let Some((_, kind, entity_type)) = &info {
1075        budget.charge(
1076            1,
1077            kind.len() + entity_type.as_deref().map_or(0, str::len),
1078            "resolving rewire endpoint contracts",
1079        )?;
1080    }
1081    Ok(info)
1082}
1083
1084/// `true` if `(src_sub, src_kind, src_type) -[relation]-> (tgt_sub, tgt_kind, tgt_type)`
1085/// is permitted under the base ADR-002 entity allowlist or a pack-declared
1086/// `EdgeEndpointRule` — the exact same `endpoint_matches` semantics `link`'s
1087/// `validate_edge_relation_endpoints` applies (khive-runtime/src/operations.rs),
1088/// reused here rather than re-derived, per the #543/#621 lesson that a parallel
1089/// matcher drifts out of sync with the validator.
1090///
1091/// `annotates` is exempt: its source-must-be-a-note constraint is enforced at
1092/// edge creation and unchanged by rewiring (an entity merge only ever rewires
1093/// its unfiltered target; a note merge rewiring the source substitutes another
1094/// note), and its target may be any substrate. Callers short-circuit `annotates`
1095/// before endpoint resolution — an annotates target may be an event or an edge,
1096/// which `resolve_merge_edge_endpoint` cannot resolve; the exemption here is
1097/// kept as defense in depth.
1098// REASON: the two endpoints each need substrate/kind/entity_type independently —
1099// collapsing them into a tuple/struct would obscure which side is which at call
1100// sites that already pass them as separate locals.
1101#[allow(clippy::too_many_arguments)]
1102fn merge_rewire_endpoint_contract_allows(
1103    pack_rules: &[EdgeEndpointRule],
1104    relation: EdgeRelation,
1105    src_sub: &str,
1106    src_kind: &str,
1107    src_type: Option<&str>,
1108    tgt_sub: &str,
1109    tgt_kind: &str,
1110    tgt_type: Option<&str>,
1111) -> bool {
1112    if relation == EdgeRelation::Annotates {
1113        return true;
1114    }
1115    // Same-substrate relations permit any note→note pair unconditionally,
1116    // matching `validate_edge_relation_endpoints`'s `(Note, Note) => {}` arm.
1117    if src_sub == "note" && tgt_sub == "note" && crate::pack::is_special_relation(relation) {
1118        return true;
1119    }
1120    if src_sub == "entity"
1121        && tgt_sub == "entity"
1122        && base_entity_rule_allows(src_kind, relation, tgt_kind)
1123    {
1124        return true;
1125    }
1126    pack_rules.iter().any(|r| {
1127        r.relation == relation
1128            && endpoint_matches(&r.source, src_sub, src_kind, src_type)
1129            && endpoint_matches(&r.target, tgt_sub, tgt_kind, tgt_type)
1130    })
1131}
1132
1133// ---------------------------------------------------------------------------
1134// Implementation
1135// ---------------------------------------------------------------------------
1136
1137impl KhiveRuntime {
1138    /// Patch-style entity update.
1139    ///
1140    /// Only fields set to `Some(_)` are changed. Re-indexes FTS5 (and vectors if configured)
1141    /// when `name`, `description`, or `entity_type` changes; skips re-indexing for
1142    /// property/tag-only patches.
1143    ///
1144    /// Returns `RuntimeError::NotFound` if the entity does not exist or belongs to a different
1145    /// namespace. Namespace isolation is enforced at the runtime layer.
1146    /// Computes the patched `Entity`, `reindex_required`, and `changed_fields` without
1147    /// writing anything, so both the normal write path and the atomic-prepare path
1148    /// share one source of truth for what a patched entity looks like.
1149    pub(crate) async fn prepare_update_entity(
1150        &self,
1151        token: &NamespaceToken,
1152        id: Uuid,
1153        patch: EntityPatch,
1154    ) -> RuntimeResult<(Entity, bool, Vec<&'static str>, i64, Option<i64>)> {
1155        self.prepare_guarded_entity_update(token, id, patch, None, &[])
1156            .await
1157    }
1158
1159    async fn prepare_guarded_entity_update(
1160        &self,
1161        token: &NamespaceToken,
1162        id: Uuid,
1163        patch: EntityPatch,
1164        expected: Option<&Entity>,
1165        remove_properties: &[&str],
1166    ) -> RuntimeResult<(Entity, bool, Vec<&'static str>, i64, Option<i64>)> {
1167        crate::secret_gate::reject_reserved_secret_gate_property(patch.properties.as_ref())?;
1168        if !remove_properties.is_empty() {
1169            let removals = Value::Object(
1170                remove_properties
1171                    .iter()
1172                    .map(|key| ((*key).to_string(), Value::Null))
1173                    .collect(),
1174            );
1175            crate::secret_gate::reject_reserved_secret_gate_property(Some(&removals))?;
1176        }
1177        if let Some(ref name) = patch.name {
1178            crate::secret_gate::check_at(name, "entity", "name")?;
1179        }
1180        if let Some(Some(ref desc)) = patch.description {
1181            crate::secret_gate::check_at(desc, "entity", "description")?;
1182        }
1183        if let Some(ref props) = patch.properties {
1184            crate::secret_gate::check_json_at(props, "entity", "properties")?;
1185        }
1186        if let Some(ref tags) = patch.tags {
1187            crate::secret_gate::check_tags_at(tags, "entity", "tags")?;
1188        }
1189        let store = self.entities(token)?;
1190        let mut entity = store.get_entity(id).await?.ok_or_else(|| {
1191            if expected.is_some() {
1192                stale_entity_snapshot_error(id)
1193            } else {
1194                RuntimeError::NotFound(format!("entity {id}"))
1195            }
1196        })?;
1197        if let Some(expected) = expected {
1198            let actual = serde_json::to_value(&entity)
1199                .map_err(|error| RuntimeError::Internal(error.to_string()))?;
1200            let expected = serde_json::to_value(expected)
1201                .map_err(|error| RuntimeError::Internal(error.to_string()))?;
1202            if actual != expected {
1203                return Err(stale_entity_snapshot_error(id));
1204            }
1205        }
1206        let expected_updated_at = entity.updated_at;
1207        let expected_deleted_at = entity.deleted_at;
1208        #[cfg(test)]
1209        race_seam::pause_after_read().await;
1210
1211        // ADR-014 tri-state: outer `None` = unchanged; `Some(None)` = explicit
1212        // clear (no vocabulary validation — there is no value to validate);
1213        // `Some(Some(raw))` = set, validated and normalized.
1214        let validated_entity_type = match &patch.entity_type {
1215            Some(None) => Some(None),
1216            Some(Some(raw)) => Some(Some(
1217                self.validate_entity_type_for_kind(&entity.kind, Some(raw))?
1218                    .expect("set branch always yields a normalized value"),
1219            )),
1220            None => None,
1221        };
1222
1223        let mut reindex_required = false;
1224        let mut changed_fields: Vec<&'static str> = Vec::new();
1225
1226        if let Some(name) = patch.name {
1227            reindex_required |= entity.name != name;
1228            entity.name = name;
1229            changed_fields.push("name");
1230        }
1231        if let Some(desc_patch) = patch.description {
1232            reindex_required |= entity.description != desc_patch;
1233            entity.description = desc_patch;
1234            changed_fields.push("description");
1235        }
1236        if let Some(props) = patch.properties {
1237            let (merged, _) = merge_properties(
1238                &entity.properties,
1239                &Some(props),
1240                EntityDedupMergePolicy::PreferFrom,
1241            );
1242            entity.properties = merged;
1243            changed_fields.push("properties");
1244        }
1245        if let Some(Value::Object(properties)) = entity.properties.as_mut() {
1246            let mut removed = false;
1247            for key in remove_properties {
1248                removed |= properties.remove(*key).is_some();
1249            }
1250            if removed && !changed_fields.contains(&"properties") {
1251                changed_fields.push("properties");
1252            }
1253        }
1254        if let Some(tags) = patch.tags {
1255            entity.tags = tags;
1256            changed_fields.push("tags");
1257        }
1258        if let Some(entity_type) = validated_entity_type {
1259            reindex_required |= entity.entity_type != entity_type;
1260            entity.entity_type = entity_type;
1261            changed_fields.push("entity_type");
1262        }
1263
1264        if expected.is_some() && changed_fields.is_empty() {
1265            return Ok((
1266                entity,
1267                reindex_required,
1268                changed_fields,
1269                expected_updated_at,
1270                expected_deleted_at,
1271            ));
1272        }
1273
1274        // #2943: `entity.properties`, `entity.entity_type`, and `entity.tags`
1275        // are all final here — the owning pack's KindHook, if any, validates
1276        // the resulting record, mirroring `prepare_note_update_hook` on the
1277        // note side. `Ok(())` when no pack registered a hook for this entity
1278        // kind (the trait default, or the runtime-layer aggregate was never
1279        // installed). Placed after the no-op early return above so a
1280        // genuinely unchanged guarded update never re-runs the hook for
1281        // nothing; only `updated_at` remains to be bumped after this point.
1282        if let Some(hook) = self.entity_kind_hook(&entity.kind) {
1283            hook.validate_entity_update(self, token, &entity, entity.properties.as_ref())
1284                .await?;
1285        }
1286
1287        // `updated_at` is also the optimistic-concurrency revision for
1288        // full-entity replacement. Make it strictly advance even when two
1289        // operations land inside one clock microsecond. Saturation is not a
1290        // valid fallback: reusing i64::MAX would make the CAS accept a write
1291        // without advancing its revision.
1292        let minimum_updated_at = expected_updated_at.checked_add(1).ok_or_else(|| {
1293            RuntimeError::Internal(format!(
1294                "entity {id} updated_at is already at i64::MAX and cannot advance"
1295            ))
1296        })?;
1297        entity.updated_at = chrono::Utc::now()
1298            .timestamp_micros()
1299            .max(minimum_updated_at);
1300        Ok((
1301            entity,
1302            reindex_required,
1303            changed_fields,
1304            expected_updated_at,
1305            expected_deleted_at,
1306        ))
1307    }
1308
1309    pub async fn update_entity(
1310        &self,
1311        token: &NamespaceToken,
1312        id: Uuid,
1313        patch: EntityPatch,
1314    ) -> RuntimeResult<Entity> {
1315        Ok(self
1316            .update_entity_with_embedding_report(token, id, patch)
1317            .await?
1318            .0)
1319    }
1320
1321    pub async fn update_entity_with_embedding_report(
1322        &self,
1323        token: &NamespaceToken,
1324        id: Uuid,
1325        patch: EntityPatch,
1326    ) -> RuntimeResult<(Entity, crate::retrieval::EmbeddingTruncationReport)> {
1327        self.update_entity_with_expected_version_and_embedding_report(token, id, patch, None)
1328            .await
1329    }
1330
1331    /// Entity update with an optional caller revision, checked inside the writer transaction.
1332    pub async fn update_entity_with_expected_version_and_embedding_report(
1333        &self,
1334        token: &NamespaceToken,
1335        id: Uuid,
1336        patch: EntityPatch,
1337        expected_version: Option<i64>,
1338    ) -> RuntimeResult<(Entity, crate::retrieval::EmbeddingTruncationReport)> {
1339        crate::entity_write::validate_expected_version(expected_version)?;
1340        let (entity, reindex_required, changed_fields, expected_updated_at, expected_deleted_at) =
1341            self.prepare_update_entity(token, id, patch).await?;
1342
1343        self.persist_prepared_entity_update(
1344            token,
1345            entity,
1346            reindex_required,
1347            changed_fields,
1348            expected_updated_at,
1349            expected_deleted_at,
1350            expected_version,
1351        )
1352        .await
1353    }
1354
1355    /// Apply an admin patch only if the entity still matches the full read snapshot.
1356    /// Property removals apply after the normal merge and preserve all other keys.
1357    /// Missing keys alone are a no-op; reserved runtime-owned keys cannot be removed.
1358    /// A changed, deleted, or missing entity returns a conflict without writing.
1359    pub async fn update_entity_if_unchanged(
1360        &self,
1361        token: &NamespaceToken,
1362        expected: &Entity,
1363        patch: EntityPatch,
1364        remove_properties: &[&str],
1365    ) -> RuntimeResult<Entity> {
1366        let (entity, reindex_required, changed_fields, expected_updated_at, expected_deleted_at) =
1367            self.prepare_guarded_entity_update(
1368                token,
1369                expected.id,
1370                patch,
1371                Some(expected),
1372                remove_properties,
1373            )
1374            .await?;
1375        if changed_fields.is_empty() {
1376            return Ok(entity);
1377        }
1378        Ok(self
1379            .persist_prepared_entity_update(
1380                token,
1381                entity,
1382                reindex_required,
1383                changed_fields,
1384                expected_updated_at,
1385                expected_deleted_at,
1386                None,
1387            )
1388            .await?
1389            .0)
1390    }
1391
1392    #[allow(clippy::too_many_arguments)]
1393    async fn persist_prepared_entity_update(
1394        &self,
1395        token: &NamespaceToken,
1396        mut entity: Entity,
1397        reindex_required: bool,
1398        changed_fields: Vec<&'static str>,
1399        expected_updated_at: i64,
1400        expected_deleted_at: Option<i64>,
1401        expected_version: Option<i64>,
1402    ) -> RuntimeResult<(Entity, crate::retrieval::EmbeddingTruncationReport)> {
1403        let id = entity.id;
1404        let _ = self.entities(token)?;
1405        let next_version = entity
1406            .version
1407            .checked_add(1)
1408            .ok_or_else(|| RuntimeError::InvalidInput("entity version overflow".into()))?;
1409        use crate::atomic_plan::{AffectedRowGuard, PlanStatement, PostCommitEffect, UpdatePlan};
1410        use crate::atomic_runner::{
1411            run_atomic_unit, AtomicOpFailure, AtomicOpPlan, AtomicRunOutcome,
1412        };
1413        let plan = UpdatePlan {
1414            target_id: id,
1415            statements: vec![PlanStatement {
1416                statement: khive_db::stores::entity::entity_replace_if_unchanged_statement(
1417                    &entity,
1418                    expected_updated_at,
1419                    expected_deleted_at,
1420                ),
1421                guard: Some(AffectedRowGuard::exactly(1)),
1422            }],
1423            post_commit: PostCommitEffect::None,
1424            edge_natural_key: None,
1425            idempotent_noop: false,
1426            entity_guard: expected_version.map(|expected_version| {
1427                crate::entity_write::EntityWriteGuard {
1428                    id,
1429                    expected_version,
1430                }
1431            }),
1432            note_guard: None,
1433            note_vector_purge: None,
1434            note_embedding_inheritance: None,
1435            graph_effects: Vec::new(),
1436        };
1437        match run_atomic_unit(
1438            self.sql().as_ref(),
1439            vec![AtomicOpPlan::Update(Box::new(plan))],
1440        )
1441        .await
1442        {
1443            Ok(AtomicRunOutcome::Committed { .. }) => entity.version = next_version,
1444            Ok(AtomicRunOutcome::RolledBack {
1445                failure: AtomicOpFailure::EntityConflict(conflict),
1446                ..
1447            }) => return Err(conflict.into_error().into()),
1448            Ok(AtomicRunOutcome::RolledBack {
1449                failure: AtomicOpFailure::GuardFailed { .. },
1450                ..
1451            }) => return Err(stale_entity_snapshot_error(id)),
1452            Ok(AtomicRunOutcome::RolledBack { failure, .. }) => {
1453                return Err(RuntimeError::Internal(format!(
1454                    "entity update rolled back: {failure:?}"
1455                )))
1456            }
1457            Err(error) => return Err(RuntimeError::Storage(error.0)),
1458        }
1459
1460        let embedding_report = if reindex_required {
1461            self.reindex_entity(token, &entity).await?
1462        } else {
1463            crate::retrieval::EmbeddingTruncationReport::default()
1464        };
1465
1466        let event_token =
1467            token.with_namespace(crate::Namespace::parse(&entity.namespace).map_err(|error| {
1468                RuntimeError::Internal(format!("entity namespace invalid: {error}"))
1469            })?);
1470        let event_store = self.events(&event_token)?;
1471        let event = khive_storage::event::Event::new(
1472            entity.namespace.clone(),
1473            "update",
1474            EventKind::EntityUpdated,
1475            SubstrateKind::Entity,
1476            "",
1477        )
1478        .with_target(entity.id)
1479        .with_payload(serde_json::json!({
1480            "id": entity.id,
1481            "namespace": entity.namespace,
1482            "changed_fields": changed_fields,
1483        }));
1484        event_store.append_event(event).await.map_err(|e| {
1485            RuntimeError::Internal(format!("update_entity: event store write failed: {e}"))
1486        })?;
1487
1488        Ok((entity, embedding_report))
1489    }
1490
1491    /// Merge `from_id` into `into_id`.
1492    ///
1493    /// All edges incident to `from_id` are rewired to `into_id`. Self-loops that would
1494    /// result from the rewire are dropped. Properties and tags are merged per `strategy`.
1495    /// `from_id` is tombstoned with merge provenance and removed from indexes. Returns a summary.
1496    ///
1497    /// If `dry_run` is true, computes and returns the planned summary without mutating any rows.
1498    ///
1499    /// Atomic: all SQL (entity reads/writes, edge rewires, FTS updates, vec-index
1500    /// delete, merge event with destructive edge preimages) runs on one pool
1501    /// connection inside one `BEGIN IMMEDIATE` transaction via
1502    /// `merge_entity_sql`. If embedding vectors are configured, the vector re-insert for
1503    /// `into_id` is performed after the transaction (requires async embedding computation).
1504    pub async fn merge_entity(
1505        &self,
1506        token: &NamespaceToken,
1507        into_id: Uuid,
1508        from_id: Uuid,
1509        strategy: EntityDedupMergePolicy,
1510        content_strategy: ContentMergeStrategy,
1511        dry_run: bool,
1512    ) -> RuntimeResult<MergeSummary> {
1513        self.merge_entity_with_reason(
1514            token,
1515            into_id,
1516            from_id,
1517            strategy,
1518            content_strategy,
1519            dry_run,
1520            None,
1521        )
1522        .await
1523    }
1524
1525    /// Merge `from_id` into `into_id` and include an optional reason in the audit event.
1526    // REASON: these arguments mirror the merge verb's policy, content strategy,
1527    // dry-run, and audit-reason fields; a builder would only move that surface.
1528    #[allow(clippy::too_many_arguments)]
1529    pub async fn merge_entity_with_reason(
1530        &self,
1531        token: &NamespaceToken,
1532        into_id: Uuid,
1533        from_id: Uuid,
1534        strategy: EntityDedupMergePolicy,
1535        content_strategy: ContentMergeStrategy,
1536        dry_run: bool,
1537        reason: Option<String>,
1538    ) -> RuntimeResult<MergeSummary> {
1539        self.merge_entity_with_validation(
1540            token,
1541            into_id,
1542            from_id,
1543            strategy,
1544            content_strategy,
1545            dry_run,
1546            reason,
1547            EntityMergeValidation::LegacyKind,
1548        )
1549        .await
1550    }
1551
1552    /// Merge two entities with an explicit override for the entity safety floor.
1553    ///
1554    /// Non-forced calls enforce entity kind, name similarity, and project compatibility
1555    /// against the rows reread inside the merge transaction. Legacy merge methods retain
1556    /// their historical same-kind-only policy.
1557    /// A non-dry-run override is recorded as `force: true` in the merge event.
1558    // REASON: these arguments mirror the merge verb's policy, content strategy,
1559    // dry-run, audit-reason, and force fields; a builder would only move that surface.
1560    #[allow(clippy::too_many_arguments)]
1561    pub async fn merge_entity_with_reason_and_force(
1562        &self,
1563        token: &NamespaceToken,
1564        into_id: Uuid,
1565        from_id: Uuid,
1566        strategy: EntityDedupMergePolicy,
1567        content_strategy: ContentMergeStrategy,
1568        dry_run: bool,
1569        reason: Option<String>,
1570        force: bool,
1571    ) -> RuntimeResult<MergeSummary> {
1572        let validation = if force {
1573            EntityMergeValidation::Forced
1574        } else {
1575            EntityMergeValidation::SafetyFloor
1576        };
1577        self.merge_entity_with_validation(
1578            token,
1579            into_id,
1580            from_id,
1581            strategy,
1582            content_strategy,
1583            dry_run,
1584            reason,
1585            validation,
1586        )
1587        .await
1588    }
1589
1590    #[allow(clippy::too_many_arguments)]
1591    async fn merge_entity_with_validation(
1592        &self,
1593        token: &NamespaceToken,
1594        into_id: Uuid,
1595        from_id: Uuid,
1596        strategy: EntityDedupMergePolicy,
1597        content_strategy: ContentMergeStrategy,
1598        dry_run: bool,
1599        reason: Option<String>,
1600        validation: EntityMergeValidation,
1601    ) -> RuntimeResult<MergeSummary> {
1602        if let Some(reason) = reason.as_deref() {
1603            crate::secret_gate::check_at(reason, "merge", "reason")?;
1604        }
1605        if into_id == from_id {
1606            return Err(RuntimeError::InvalidInput(
1607                "cannot merge an entity into itself".into(),
1608            ));
1609        }
1610        let ns = token.namespace().as_str().to_owned();
1611        let fts_table = "fts_entities".to_string();
1612        // One immutable registry view governs transactional deletion, table
1613        // preparation, and survivor reindex. A late model belongs to a later
1614        // write/backfill rather than only one leg of this merge.
1615        let embedding_plan = EmbeddingModelPlan::capture(self);
1616        let vec_tables = embedding_plan.vector_tables();
1617        // Loaded once here (sync, cheap) so the rewire loop can evaluate the
1618        // endpoint contract without an async round-trip per edge (khive#1216).
1619        let pack_rules = self.pack_edge_rules();
1620
1621        // Ensure all required tables exist (idempotent DDL) before the transaction.
1622        let _ = self.entities(token)?;
1623        let _ = self.graph(token)?;
1624        let _ = self.text(token)?;
1625        let _ = self.events(token)?;
1626        // vectors_for_model (not the default-model-only self.vectors()) so
1627        // custom-only runtimes (no default embedding_model) still get DDL primed.
1628        for model_name in embedding_plan.model_names() {
1629            let _ = self.vectors_for_model(token, model_name)?;
1630        }
1631
1632        let pool = self.backend().pool_arc();
1633        let writer_task = pool
1634            .writer_task_for_runtime_write(RuntimeWriteOperation::MergeEntity)
1635            .map_err(RuntimeError::Storage)?;
1636        // Minted before the transaction so the tombstone and its in-transaction
1637        // EntityMerged event carry the same id.
1638        let merge_event_id = Uuid::new_v4();
1639        let event_context = MergeEventContext {
1640            attribution: EventAttribution::from_token(token),
1641            reason,
1642            force: validation == EntityMergeValidation::Forced,
1643            strategy,
1644            content_strategy,
1645            kind: EventKind::EntityMerged,
1646            substrate: SubstrateKind::Entity,
1647            event_id: Some(merge_event_id),
1648        };
1649
1650        let (mut summary, updated_entity) = if let Some(writer_task) = writer_task {
1651            writer_task
1652                .send(move |conn| {
1653                    merge_entity_sql(
1654                        conn,
1655                        ns,
1656                        fts_table,
1657                        vec_tables,
1658                        into_id,
1659                        from_id,
1660                        strategy,
1661                        content_strategy,
1662                        dry_run,
1663                        pack_rules,
1664                        validation,
1665                        MergeTxLimits::default(),
1666                        merge_event_id,
1667                        Some(event_context),
1668                    )
1669                    .map_err(|e| {
1670                        khive_storage::StorageError::driver(
1671                            khive_storage::StorageCapability::Entities,
1672                            "merge_entity",
1673                            e,
1674                        )
1675                    })
1676                })
1677                .await
1678                .map_err(map_merge_entity_storage_error)?
1679        } else {
1680            tokio::task::spawn_blocking(move || {
1681                let guard = pool.writer()?;
1682                let mut refusal = None;
1683                let result = guard.transaction(|conn| {
1684                    merge_entity_sql(
1685                        conn,
1686                        ns,
1687                        fts_table,
1688                        vec_tables,
1689                        into_id,
1690                        from_id,
1691                        strategy,
1692                        content_strategy,
1693                        dry_run,
1694                        pack_rules,
1695                        validation,
1696                        MergeTxLimits::default(),
1697                        merge_event_id,
1698                        Some(event_context),
1699                    )
1700                    .map_err(|error| match error {
1701                        MergeSqlError::Sqlite(error) => error,
1702                        MergeSqlError::Refusal(error) => {
1703                            refusal = Some(error);
1704                            SqliteError::InvalidData(
1705                                "entity merge refused by transactional policy".to_string(),
1706                            )
1707                        }
1708                    })
1709                });
1710                match refusal {
1711                    Some(error) => Err(error),
1712                    None => result.map_err(RuntimeError::from),
1713                }
1714            })
1715            .await
1716            .map_err(|e| RuntimeError::Internal(e.to_string()))??
1717        };
1718
1719        // Count only committed event rows; dry-run never inserts an event.
1720        if !dry_run {
1721            khive_storage::usage::count(khive_storage::usage::UsageUnit::EventRows, 1);
1722            tracing::info!(
1723                into_id = %summary.kept_id,
1724                from_id = %summary.removed_id,
1725                budget_rows = summary.tx_budget.rows_charged,
1726                budget_bytes = summary.tx_budget.bytes_charged,
1727                budget_max_rows = summary.tx_budget.max_rows,
1728                budget_max_bytes = summary.tx_budget.max_bytes,
1729                "merge_entity: transaction materialization budget"
1730            );
1731        }
1732
1733        // FTS and vec-deletes already committed inside the transaction above;
1734        // only the embedding re-insert needs an async step outside it.
1735        if !dry_run && !embedding_plan.is_empty() {
1736            summary.embedding_truncation = self
1737                .reindex_entity_with_plan(token, &updated_entity, &embedding_plan)
1738                .await?;
1739        }
1740
1741        Ok(summary)
1742    }
1743
1744    // ---- Internal helpers ----
1745
1746    async fn apply_entity_index_revision(
1747        &self,
1748        entity: &Entity,
1749        statements: Vec<SqlStatement>,
1750    ) -> RuntimeResult<bool> {
1751        let namespace = entity.namespace.clone();
1752        let id = entity.id.to_string();
1753        let version = entity.version;
1754        let op: AtomicUnitOp = Box::new(move |writer| {
1755            Box::pin(async move {
1756                let current = writer
1757                    .query_scalar(SqlStatement {
1758                        sql: "SELECT version FROM entities \
1759                              WHERE namespace=?1 AND id=?2 AND deleted_at IS NULL"
1760                            .into(),
1761                        params: vec![SqlValue::Text(namespace), SqlValue::Text(id)],
1762                        label: Some("entity-index-revision".into()),
1763                    })
1764                    .await?;
1765                if !matches!(current, Some(SqlValue::Integer(current)) if current == version) {
1766                    return Ok(Box::new(false) as Box<dyn Any + Send>);
1767                }
1768                for statement in statements {
1769                    writer.execute(statement).await?;
1770                }
1771                Ok(Box::new(true) as Box<dyn Any + Send>)
1772            })
1773        });
1774        self.sql()
1775            .atomic_unit(op)
1776            .await?
1777            .downcast::<bool>()
1778            .map(|result| *result)
1779            .map_err(|_| RuntimeError::Internal("invalid entity index outcome".into()))
1780    }
1781
1782    fn entity_vector_insert_statements(
1783        table: &str,
1784        entity: &Entity,
1785        model_name: &str,
1786        vector: &[f32],
1787    ) -> Vec<SqlStatement> {
1788        let subject = entity.id.to_string();
1789        let kind = SubstrateKind::Entity.to_string();
1790        let field = "entity.body";
1791        let blob = vector
1792            .iter()
1793            .flat_map(|value| value.to_ne_bytes())
1794            .collect();
1795        vec![
1796            SqlStatement {
1797                sql: format!(
1798                    "INSERT INTO ann_write_log \
1799                     (namespace, embedding_model, kind, field, subject_id, op) \
1800                     SELECT namespace, embedding_model, kind, field, subject_id, 'delete' \
1801                     FROM {table} WHERE subject_id=?1 AND NOT \
1802                     (namespace=?2 AND embedding_model=?3 AND kind=?4 AND field=?5)"
1803                ),
1804                params: vec![
1805                    SqlValue::Text(subject.clone()),
1806                    SqlValue::Text(entity.namespace.clone()),
1807                    SqlValue::Text(model_name.to_string()),
1808                    SqlValue::Text(kind.clone()),
1809                    SqlValue::Text(field.into()),
1810                ],
1811                label: Some("entity-reindex-log-delete".into()),
1812            },
1813            SqlStatement {
1814                sql: format!("DELETE FROM {table} WHERE subject_id=?1"),
1815                params: vec![SqlValue::Text(subject.clone())],
1816                label: Some("entity-reindex-vector-delete".into()),
1817            },
1818            SqlStatement {
1819                sql: format!(
1820                    "INSERT INTO {table} \
1821                     (subject_id, namespace, kind, field, embedding_model, embedding) \
1822                     VALUES (?1, ?2, ?3, ?4, ?5, ?6)"
1823                ),
1824                params: vec![
1825                    SqlValue::Text(subject.clone()),
1826                    SqlValue::Text(entity.namespace.clone()),
1827                    SqlValue::Text(kind.clone()),
1828                    SqlValue::Text(field.into()),
1829                    SqlValue::Text(model_name.to_string()),
1830                    SqlValue::Blob(blob),
1831                ],
1832                label: Some("entity-reindex-vector-insert".into()),
1833            },
1834            SqlStatement {
1835                sql: "INSERT INTO ann_write_log \
1836                      (namespace, embedding_model, kind, field, subject_id, op) \
1837                      VALUES (?1, ?2, ?3, ?4, ?5, 'upsert')"
1838                    .into(),
1839                params: vec![
1840                    SqlValue::Text(entity.namespace.clone()),
1841                    SqlValue::Text(model_name.to_string()),
1842                    SqlValue::Text(kind),
1843                    SqlValue::Text(field.into()),
1844                    SqlValue::Text(subject),
1845                ],
1846                label: Some("entity-reindex-log-upsert".into()),
1847            },
1848        ]
1849    }
1850
1851    /// Re-upsert FTS5 document and vector(s) for the entity across all registered models.
1852    ///
1853    /// Uses `entity.namespace` — the authoritative namespace stored on the record — rather
1854    /// than the caller-supplied `namespace` parameter. This prevents a cross-namespace
1855    /// reindex from writing the search document into the wrong namespace's FTS index.
1856    ///
1857    /// Best-effort for vectors: if embedding or inserting for a particular model fails,
1858    /// logs a warning and continues to the next model. The FTS step is fail-closed
1859    /// (propagates error). Callers (update_entity, merge_entity) have already committed
1860    /// the entity row, so a partial embed miss leaves a stale vector rather than
1861    /// rolling back the update. Each failed guarded vector replacement rolls back
1862    /// its own index writes, keeping the prior row searchable.
1863    pub(crate) async fn reindex_entity(
1864        &self,
1865        token: &NamespaceToken,
1866        entity: &Entity,
1867    ) -> RuntimeResult<crate::retrieval::EmbeddingTruncationReport> {
1868        let embedding_plan = EmbeddingModelPlan::capture(self);
1869        self.reindex_entity_with_plan(token, entity, &embedding_plan)
1870            .await
1871    }
1872
1873    async fn reindex_entity_with_plan(
1874        &self,
1875        token: &NamespaceToken,
1876        entity: &Entity,
1877        embedding_plan: &EmbeddingModelPlan,
1878    ) -> RuntimeResult<crate::retrieval::EmbeddingTruncationReport> {
1879        // Use entity.namespace (authoritative) rather than token.namespace().as_str() (caller claim).
1880        let doc = entity_fts_document(entity);
1881        let embed_body = doc.body.clone();
1882        let _ = self.text(token)?;
1883        #[cfg(test)]
1884        race_seam::pause_before_entity_index_publish().await;
1885        let statements = khive_db::stores::text::delete_document_statements(
1886            "fts_entities",
1887            &entity.namespace,
1888            entity.id,
1889        )
1890        .into_iter()
1891        .chain(khive_db::stores::text::insert_document_statements(
1892            "fts_entities",
1893            &doc,
1894        ))
1895        .collect();
1896        if !self.apply_entity_index_revision(entity, statements).await? {
1897            return Ok(crate::retrieval::EmbeddingTruncationReport::default());
1898        }
1899
1900        let mut report = crate::retrieval::EmbeddingTruncationReport::default();
1901        for model_name in embedding_plan.model_names() {
1902            match self
1903                .embed_document_with_model_outcome_for_token(token, model_name, &embed_body)
1904                .await
1905            {
1906                Ok(outcome) => {
1907                    report.observe(&outcome);
1908                    match self.vectors_for_model(token, model_name) {
1909                        Ok(_) => {
1910                            if let Some(index) =
1911                                outcome.vector.iter().position(|value| !value.is_finite())
1912                            {
1913                                tracing::warn!(
1914                                    model = model_name,
1915                                    id = %entity.id,
1916                                    index,
1917                                    "reindex_entity: non-finite vector, skipping model"
1918                                );
1919                                continue;
1920                            }
1921                            let (storage_model, dimensions) = match self
1922                                .vector_model_metadata(model_name)
1923                            {
1924                                Ok(metadata) => metadata,
1925                                Err(e) => {
1926                                    tracing::warn!(
1927                                        model = model_name,
1928                                        id = %entity.id,
1929                                        "reindex_entity: could not resolve vector model, skipping: {e}"
1930                                    );
1931                                    continue;
1932                                }
1933                            };
1934                            if outcome.vector.len() != dimensions {
1935                                tracing::warn!(
1936                                    model = model_name,
1937                                    id = %entity.id,
1938                                    "reindex_entity: vector dimensions do not match model, skipping"
1939                                );
1940                                continue;
1941                            }
1942                            let table =
1943                                format!("vec_{}", crate::config::sanitize_key(&storage_model));
1944                            let statements = Self::entity_vector_insert_statements(
1945                                &table,
1946                                entity,
1947                                &storage_model,
1948                                &outcome.vector,
1949                            );
1950                            #[cfg(test)]
1951                            race_seam::pause_before_entity_vector_publish().await;
1952                            match self.apply_entity_index_revision(entity, statements).await {
1953                                Ok(true) => {}
1954                                Ok(false) => break,
1955                                Err(e) => {
1956                                    tracing::warn!(
1957                                        model = model_name,
1958                                        id = %entity.id,
1959                                        "reindex_entity: vector insert failed, skipping model: {e}"
1960                                    );
1961                                }
1962                            }
1963                        }
1964                        Err(e) => {
1965                            tracing::warn!(
1966                                model = model_name,
1967                                id = %entity.id,
1968                                "reindex_entity: could not access vector store for model, skipping: {e}"
1969                            );
1970                        }
1971                    }
1972                }
1973                Err(e) => {
1974                    tracing::warn!(
1975                        model = model_name,
1976                        id = %entity.id,
1977                        "reindex_entity: embed failed for model, skipping: {e}"
1978                    );
1979                }
1980            }
1981        }
1982
1983        Ok(report)
1984    }
1985
1986    /// Remove an entity from FTS5 and vector indexes across all registered models.
1987    pub(crate) async fn remove_from_indexes(
1988        &self,
1989        token: &NamespaceToken,
1990        id: Uuid,
1991    ) -> RuntimeResult<()> {
1992        let ns = token.namespace().as_str().to_owned();
1993        self.text(token)?.delete_document(&ns, id).await?;
1994        for model_name in self.registered_embedding_model_names() {
1995            self.vectors_for_model(token, &model_name)?
1996                .delete(id)
1997                .await?;
1998        }
1999        Ok(())
2000    }
2001
2002    /// Re-upsert FTS5 document and vector(s) for the note across all registered models.
2003    ///
2004    /// Best-effort for vectors: mirrors reindex_entity's warn-and-continue policy.
2005    pub(crate) async fn reindex_note(
2006        &self,
2007        token: &NamespaceToken,
2008        note: &khive_storage::note::Note,
2009    ) -> RuntimeResult<crate::retrieval::EmbeddingTruncationReport> {
2010        let embedding_plan = EmbeddingModelPlan::capture(self);
2011        self.reindex_note_with_plan(token, note, &embedding_plan)
2012            .await
2013    }
2014
2015    async fn reindex_note_with_plan(
2016        &self,
2017        token: &NamespaceToken,
2018        note: &khive_storage::note::Note,
2019        embedding_plan: &EmbeddingModelPlan,
2020    ) -> RuntimeResult<crate::retrieval::EmbeddingTruncationReport> {
2021        let statements = khive_db::stores::text::delete_document_statements(
2022            "fts_notes",
2023            &note.namespace,
2024            note.id,
2025        )
2026        .into_iter()
2027        .chain(khive_db::stores::text::insert_document_statements(
2028            "fts_notes",
2029            &note_fts_document(note),
2030        ))
2031        .collect();
2032        if !self.apply_note_index_revision(note, statements).await? {
2033            return Ok(crate::retrieval::EmbeddingTruncationReport::default());
2034        }
2035        let mut report = crate::retrieval::EmbeddingTruncationReport::default();
2036        for model_name in embedding_plan.model_names() {
2037            match self
2038                .embed_document_with_model_outcome_for_token(
2039                    token,
2040                    model_name,
2041                    note_embedding_text_ref(note),
2042                )
2043                .await
2044            {
2045                Ok(outcome) => {
2046                    report.observe(&outcome);
2047                    match self.vectors_for_model(token, model_name) {
2048                        Ok(_) => {
2049                            if outcome.vector.iter().any(|value| !value.is_finite()) {
2050                                tracing::warn!(model = model_name, id = %note.id, "reindex_note: non-finite vector, skipping model");
2051                                continue;
2052                            }
2053                            let table = format!("vec_{}", crate::config::sanitize_key(model_name));
2054                            let statements = crate::atomic_message::vector_insert_statements(
2055                                &table,
2056                                &note.namespace,
2057                                note.id,
2058                                "note.content",
2059                                model_name,
2060                                &outcome.vector,
2061                                "note-reindex",
2062                            )
2063                            .into_iter()
2064                            .map(|planned| planned.statement)
2065                            .collect();
2066                            if let Err(e) = self.apply_note_index_revision(note, statements).await {
2067                                tracing::warn!(
2068                                    model = model_name,
2069                                    id = %note.id,
2070                                    "reindex_note: vector insert failed, skipping model: {e}"
2071                                );
2072                            }
2073                        }
2074                        Err(e) => {
2075                            tracing::warn!(
2076                                model = model_name,
2077                                id = %note.id,
2078                                "reindex_note: could not access vector store for model, skipping: {e}"
2079                            );
2080                        }
2081                    }
2082                }
2083                Err(e) => {
2084                    tracing::warn!(
2085                        model = model_name,
2086                        id = %note.id,
2087                        "reindex_note: embed failed for model, skipping: {e}"
2088                    );
2089                }
2090            }
2091        }
2092        Ok(report)
2093    }
2094
2095    /// Apply a note patch to exactly the supplied read snapshot without
2096    /// fetching the row again. The caller must persist it through
2097    /// [`Self::update_note_from_snapshot_with_embedding_report`] or a write
2098    /// plan guarded by the snapshot's `updated_at`/`deleted_at` values.
2099    pub(crate) async fn prepare_update_note_from_snapshot(
2100        &self,
2101        _token: &NamespaceToken,
2102        mut note: khive_storage::note::Note,
2103        patch: NotePatch,
2104    ) -> RuntimeResult<(khive_storage::note::Note, bool, bool)> {
2105        // The stored row as read. A no-op answers with this, not with the
2106        // patched snapshot: the patch may differ from the row in ways the
2107        // no-op decision ignores (tag order), and nothing was written.
2108        let stored = note.clone();
2109        let original_name = note.name.clone();
2110        let original_content = note.content.clone();
2111        let original_salience = note.salience;
2112        let original_decay_factor = note.decay_factor;
2113        let original_properties = note.properties.clone();
2114        let original_status = note.status.clone();
2115        if patch
2116            .update_policy
2117            .kind
2118            .as_deref()
2119            .is_some_and(|kind| kind != note.kind)
2120        {
2121            return Err(RuntimeError::InvalidInput(
2122                "note update policy does not match the stored note kind".into(),
2123            ));
2124        }
2125        if patch.content.is_some() || patch.properties.is_some() {
2126            if let Some(error) = self.stream_member_error(&note).await? {
2127                return Err(error);
2128            }
2129        }
2130        crate::secret_gate::reject_reserved_secret_gate_property(patch.properties.as_ref())?;
2131        if let Some(ref content) = patch.content {
2132            crate::secret_gate::check_at(content, "note", "content")?;
2133        }
2134        if let Some(Some(ref name)) = patch.name {
2135            crate::secret_gate::check_at(name, "note", "name")?;
2136        }
2137        if let Some(ref props) = patch.properties {
2138            crate::secret_gate::check_json_at(props, "note", "properties")?;
2139        }
2140
2141        reject_pack_managed_schedule_mutation(&note, "update")?;
2142
2143        let mut text_changed = false;
2144
2145        if let Some(name_patch) = patch.name {
2146            text_changed |= note.name != name_patch;
2147            note.name = name_patch;
2148        }
2149        if let Some(content) = patch.content {
2150            text_changed |= note.content != content;
2151            note.content = content;
2152        }
2153        if let Some(salience_patch) = patch.salience {
2154            // Reject invalid salience rather than silently clamping caller input.
2155            if let Some(s) = salience_patch {
2156                if !s.is_finite() || !(0.0..=1.0).contains(&s) {
2157                    return Err(crate::RuntimeError::InvalidInput(format!(
2158                        "salience must be a finite value in [0.0, 1.0]; got {s}"
2159                    )));
2160                }
2161            }
2162            note.salience = salience_patch;
2163        }
2164        if let Some(decay_patch) = patch.decay_factor {
2165            // Reject invalid decay_factor rather than silently clamping caller input.
2166            if let Some(d) = decay_patch {
2167                if !d.is_finite() || d < 0.0 {
2168                    return Err(crate::RuntimeError::InvalidInput(format!(
2169                        "decay_factor must be a finite value >= 0.0; got {d}"
2170                    )));
2171                }
2172            }
2173            note.decay_factor = decay_patch;
2174        }
2175        if let Some(props) = patch.properties {
2176            // Kind-owned identity is protected below pack hooks, including
2177            // direct runtime and atomic/proposal update preparation. The merge
2178            // path restores these same keys on its surviving row.
2179            let owned_keys = kind_owned_properties(&note.kind);
2180            if !owned_keys.is_empty() {
2181                let object = props.as_object().ok_or_else(|| {
2182                    if note.kind == "message" {
2183                        RuntimeError::InvalidInput(
2184                            "properties on a `message` note must be patched with an object: a \
2185                             non-object patch would replace the transport-owned quarantine and \
2186                             channel provenance established by `comm.ingest`"
2187                                .into(),
2188                        )
2189                    } else {
2190                        RuntimeError::InvalidInput(format!(
2191                            "properties on a `{}` note must be patched with an object; \
2192                             a non-object patch would erase its owner-established identity",
2193                            note.kind
2194                        ))
2195                    }
2196                })?;
2197                if let Some(named) = owned_keys.iter().find(|key| object.contains_key(**key)) {
2198                    if note.kind == "message" {
2199                        return Err(RuntimeError::InvalidInput(format!(
2200                            "`{named}` is transport-owned on a `message` note and cannot be patched; \
2201                             only `comm.ingest` may establish quarantine disposition and channel \
2202                             provenance"
2203                        )));
2204                    }
2205                    return Err(RuntimeError::InvalidInput(format!(
2206                        "`{named}` is not patchable on a `{}` note; \
2207                         use `comm.heartbeat` to report health without changing the row's identity",
2208                        note.kind
2209                    )));
2210                }
2211            }
2212            // On a pack-owned note kind, the properties in
2213            // `OWNER_ESTABLISHED_PROPERTIES` are established by the owning pack
2214            // and read back by it to decide something structural — who wrote
2215            // the record and when, which author-side record it copies, which
2216            // conversation it belongs to. A caller cannot patch them here.
2217            // Only a patch that *names* one of them is refused, and naming is
2218            // the exact test: the merge below is `PreferFrom`, so a patch that
2219            // names an owned key would overwrite it while a patch that does
2220            // not name it leaves it intact. Every other key still merges
2221            // normally — arbitrary metadata on a pack-owned record (a
2222            // `blocked_on` note on a `task`) has no other write path and must
2223            // keep working.
2224            if self.is_pack_owned_note_kind(&note.kind) {
2225                // A non-object patch names nothing, so it slips past the
2226                // named-key check below and then takes `merge_json`'s
2227                // non-object `PreferFrom` arm, which replaces the whole
2228                // property object rather than merging into it — erasing
2229                // every owned key. Refused on every pack-owned kind, not only
2230                // rows that currently carry an owned key, so an identical
2231                // call cannot succeed or fail on state the caller cannot see.
2232                if !props.is_object() {
2233                    return Err(RuntimeError::InvalidInput(format!(
2234                        "properties on a `{}` note must be patched with an object: a non-object \
2235                         patch names no key, so it would replace the whole property object rather \
2236                         than merging into it. Pass an object containing the keys you intend to \
2237                         set.",
2238                        note.kind
2239                    )));
2240                }
2241                if let Some(named) = owner_established_property_named_in(&props) {
2242                    return Err(RuntimeError::InvalidInput(format!(
2243                        "`{named}` is not patchable on a `{}` note: the pack that owns this \
2244                         kind establishes it and reads it back — to decide how the record is \
2245                         attributed and grouped, or to reproduce it verbatim when the record \
2246                         is re-emitted — so it is written by the owner and immutable to a \
2247                         caller patch. Patch any other property key here, or omit \
2248                         `{named}` from this patch.",
2249                        note.kind
2250                    )));
2251                }
2252            }
2253            let incoming_properties = Some(props);
2254            let (mut merged, _) = merge_properties(
2255                &note.properties,
2256                &incoming_properties,
2257                EntityDedupMergePolicy::PreferFrom,
2258            );
2259            if let Some(properties) = merged.as_mut().and_then(Value::as_object_mut) {
2260                for key in patch.update_policy.null_clearing_properties {
2261                    if incoming_properties
2262                        .as_ref()
2263                        .and_then(|incoming| incoming.get(*key))
2264                        .is_some_and(Value::is_null)
2265                    {
2266                        properties.remove(*key);
2267                    }
2268                }
2269            }
2270            note.properties = merged;
2271        }
2272        if let Some(status) = patch.kind_status {
2273            note.status = status;
2274        }
2275
2276        // JSON object key order is not meaningful to callers. Tags are also
2277        // set-like in every existing note reader, so their order is ignored
2278        // for the no-op decision while duplicate entries remain meaningful.
2279        // All other arrays retain ordinary JSON ordering semantics.
2280        let changed = original_name != note.name
2281            || original_content != note.content
2282            || original_salience != note.salience
2283            || original_decay_factor != note.decay_factor
2284            || !note_update_values_equal(&original_properties, &note.properties)
2285            || original_status != note.status;
2286        if !changed {
2287            return Ok((stored, text_changed, false));
2288        }
2289
2290        // `updated_at` is also the optimistic-concurrency revision for
2291        // full-note replacement. Make it strictly advance even when two
2292        // operations land inside one clock microsecond. Saturation is not a
2293        // valid fallback: reusing i64::MAX would make the CAS accept a write
2294        // without advancing its revision.
2295        let minimum_updated_at = note.updated_at.checked_add(1).ok_or_else(|| {
2296            RuntimeError::Internal(format!(
2297                "note {} updated_at is already at i64::MAX and cannot advance",
2298                note.id
2299            ))
2300        })?;
2301        note.updated_at = chrono::Utc::now()
2302            .timestamp_micros()
2303            .max(minimum_updated_at);
2304        Ok((note, text_changed, true))
2305    }
2306
2307    /// Patch-style note update.
2308    pub async fn update_note(
2309        &self,
2310        token: &NamespaceToken,
2311        id: Uuid,
2312        patch: NotePatch,
2313    ) -> RuntimeResult<khive_storage::note::Note> {
2314        Ok(self
2315            .update_note_with_embedding_report(token, id, patch)
2316            .await?
2317            .0)
2318    }
2319
2320    pub async fn update_note_with_embedding_report(
2321        &self,
2322        token: &NamespaceToken,
2323        id: Uuid,
2324        patch: NotePatch,
2325    ) -> RuntimeResult<(
2326        khive_storage::note::Note,
2327        crate::retrieval::EmbeddingTruncationReport,
2328    )> {
2329        let snapshot = self
2330            .notes(token)?
2331            .get_note(id)
2332            .await?
2333            .ok_or_else(|| RuntimeError::NotFound(format!("note {id}")))?;
2334        self.update_note_from_snapshot_with_embedding_report(token, snapshot, patch)
2335            .await
2336    }
2337
2338    /// Patch and persist one note from a caller-owned read snapshot.
2339    ///
2340    /// This is the canonical seam for kind hooks that normalize coupled
2341    /// fields from the current note. The same snapshot feeds normalization,
2342    /// patch application, and the compare-and-swap write; a concurrent note
2343    /// change therefore refuses the write instead of persisting derivations
2344    /// computed from stale state.
2345    pub async fn update_note_from_snapshot_with_embedding_report(
2346        &self,
2347        token: &NamespaceToken,
2348        snapshot: khive_storage::note::Note,
2349        patch: NotePatch,
2350    ) -> RuntimeResult<(
2351        khive_storage::note::Note,
2352        crate::retrieval::EmbeddingTruncationReport,
2353    )> {
2354        let (note, plan) = self
2355            .prepare_versioned_note_update(token, snapshot, patch)
2356            .await?;
2357        self.commit_prepared_note_update(token, note, crate::AtomicOpPlan::Update(Box::new(plan)))
2358            .await
2359    }
2360
2361    /// Commit a normalized and validated kind-owned update, including its typed
2362    /// graph companions. Callers must first run `prepare_note_update_policy`
2363    /// against this exact snapshot and pass the policy it returned; the shared
2364    /// atomic prepare seam checks all patch fields before asking the kind hook
2365    /// to derive any graph effects.
2366    pub async fn update_note_from_snapshot_with_kind_effects(
2367        &self,
2368        token: &NamespaceToken,
2369        snapshot: khive_storage::Note,
2370        args: &Value,
2371        policy: crate::NoteUpdatePolicy,
2372        registry: &crate::VerbRegistry,
2373    ) -> RuntimeResult<(
2374        khive_storage::Note,
2375        crate::retrieval::EmbeddingTruncationReport,
2376    )> {
2377        let (note, plan) = crate::atomic_prepare::prepare_update_from_note_snapshot(
2378            self, token, args, None, snapshot, policy, registry,
2379        )
2380        .await?;
2381        self.commit_prepared_note_update(token, note, plan).await
2382    }
2383
2384    async fn commit_prepared_note_update(
2385        &self,
2386        token: &NamespaceToken,
2387        note: khive_storage::Note,
2388        plan: crate::AtomicOpPlan,
2389    ) -> RuntimeResult<(
2390        khive_storage::Note,
2391        crate::retrieval::EmbeddingTruncationReport,
2392    )> {
2393        let id = note.id;
2394        use crate::atomic_runner::{run_atomic_unit, AtomicOpFailure, AtomicRunOutcome};
2395        match run_atomic_unit(self.sql().as_ref(), vec![plan]).await {
2396            Ok(AtomicRunOutcome::Committed { post_commit }) => {
2397                let outcomes = crate::atomic_prepare::apply_post_commit_effects_with_report(
2398                    self,
2399                    token,
2400                    post_commit,
2401                )
2402                .await?;
2403                let report = outcomes
2404                    .into_iter()
2405                    .next()
2406                    .map(|outcome| outcome.truncation)
2407                    .unwrap_or_default();
2408                Ok((note, report))
2409            }
2410            Ok(AtomicRunOutcome::RolledBack {
2411                failure: AtomicOpFailure::NoteConflict(conflict),
2412                ..
2413            }) => Err(conflict.into_error().into()),
2414            Ok(AtomicRunOutcome::RolledBack {
2415                failure: AtomicOpFailure::GuardFailed { .. },
2416                ..
2417            }) => Err(stale_note_snapshot_error(id)),
2418            Ok(AtomicRunOutcome::RolledBack { failure, .. }) => Err(RuntimeError::Internal(
2419                format!("note update rolled back: {failure:?}"),
2420            )),
2421            Err(error) => Err(RuntimeError::Storage(error.0)),
2422        }
2423    }
2424
2425    /// Claim `external_id` on an outbound `message` note through the
2426    /// ADR-124-sanctioned store-level owner path, bypassing the
2427    /// caller-facing owner-established-property refusal in
2428    /// [`Self::update_note`] (and its crate-internal prepare path). This is deliberately
2429    /// NOT exposed through any registered verb (ADR-124's stated bound): it is
2430    /// reachable only from pack/runtime code that owns outbox bookkeeping for
2431    /// the `message` note kind.
2432    ///
2433    /// Refuses (returns `Err`, never writes) unless the live row is a
2434    /// `message` note, `properties.direction == "outbound"`, and
2435    /// `properties.external_id` is currently absent or empty. The claim is
2436    /// committed against that exact snapshot and advances its timestamp so
2437    /// competing claims and delivery-outcome CAS writes cannot overwrite it.
2438    pub async fn claim_outbound_message_external_id(
2439        &self,
2440        token: &NamespaceToken,
2441        id: Uuid,
2442        external_id: String,
2443    ) -> RuntimeResult<khive_storage::note::Note> {
2444        let store = self.notes(token)?;
2445        let note = store
2446            .get_note(id)
2447            .await?
2448            .ok_or_else(|| RuntimeError::NotFound(format!("note {id}")))?;
2449        if note.kind != "message" {
2450            return Err(RuntimeError::InvalidInput(format!(
2451                "external_id can only be claimed on a `message` note; note {id} is a `{}`",
2452                note.kind
2453            )));
2454        }
2455        let props = note.properties.as_ref().and_then(|v| v.as_object());
2456        let direction = props
2457            .and_then(|p| p.get("direction"))
2458            .and_then(|v| v.as_str());
2459        if direction != Some("outbound") {
2460            return Err(RuntimeError::InvalidInput(format!(
2461                "external_id can only be claimed on an outbound message note; note {id} has \
2462                 direction {:?}",
2463                direction
2464            )));
2465        }
2466        if note.deleted_at.is_some()
2467            || Self::outbound_delivery_is_terminal(props)
2468            || props
2469                .and_then(|p| p.get("delivered_at"))
2470                .is_some_and(|value| !value.is_null())
2471        {
2472            return Err(RuntimeError::InvalidInput(format!(
2473                "note {id} is not pending outbound delivery"
2474            )));
2475        }
2476        let existing = props
2477            .and_then(|p| p.get("external_id"))
2478            .and_then(|v| v.as_str());
2479        if existing.is_some_and(|v| !v.is_empty()) {
2480            return Err(RuntimeError::InvalidInput(format!(
2481                "note {id} already has an external_id claimed"
2482            )));
2483        }
2484        let mut properties = props
2485            .cloned()
2486            .expect("outbound direction requires an object");
2487        properties.insert("external_id".to_string(), Value::String(external_id));
2488        self.replace_outbound_message_properties_as_owner(token, note, properties)
2489            .await
2490    }
2491
2492    /// Non-wire outbox scan for the channel delivery loops.
2493    ///
2494    /// Fetches live `message` notes matching the SQL-side pending predicate
2495    /// newest-first (`created_at DESC, id ASC`), bounded by an internal scan
2496    /// cap, and returns those that are still due, capped at `limit`.
2497    /// Direction, `delivered_at`, terminal `delivery` state and the optional
2498    /// `to_actor` channel prefix are all filtered by SQLite; only a valid
2499    /// `next_attempt_at` remains a Rust check. Pending means `delivered_at`
2500    /// is absent or null, `properties.delivery` carries no terminal state
2501    /// (`"delivered"` / `"failed"`), and a valid `next_attempt_at` is absent
2502    /// or due (ADR-122 §1). Malformed legacy deadlines fail open so a bad
2503    /// property cannot strand mail forever.
2504    ///
2505    /// The channel prefix has to be in the statement, not applied to the
2506    /// fetched page: every actor-to-actor outbound row matches the pending
2507    /// predicate forever (nothing marks those delivered), so that population
2508    /// outgrows any scan cap and a page-then-filter scan never reaches a
2509    /// channel's rows once enough other rows sort ahead of them. The prefix
2510    /// renders as an index range served by
2511    /// `idx_comm_message_outbound_recipient`, and the newest-first order
2512    /// means a future predicate miss still surfaces new rows first.
2513    /// This lives on the runtime rather than going through the wire registry
2514    /// for the same reason as
2515    /// [`Self::claim_outbound_message_external_id`]: the delivery loop must
2516    /// scan the backend that actually holds comm's notes, and under a
2517    /// `[packs.comm]` backend assignment that is not the backend serving the
2518    /// generic kg verbs.
2519    pub async fn list_undelivered_outbound_messages(
2520        &self,
2521        token: &NamespaceToken,
2522        to_prefix: Option<&str>,
2523        limit: u32,
2524    ) -> RuntimeResult<Vec<khive_storage::note::Note>> {
2525        const MAX_SCAN_TOTAL: u32 = 10_000;
2526        if limit == 0 {
2527            return Ok(Vec::new());
2528        }
2529        let now_micros = chrono::Utc::now().timestamp_micros();
2530        let mut property_filters = vec![
2531            PropertyFilter {
2532                json_path: "$.direction".to_string(),
2533                op: FilterOp::Eq,
2534                value: SqlValue::Text("outbound".to_string()),
2535            },
2536            PropertyFilter {
2537                json_path: "$.delivered_at".to_string(),
2538                op: FilterOp::JsonTypeMissingOrNullIndexed,
2539                value: SqlValue::Null,
2540            },
2541            PropertyFilter {
2542                json_path: "$.delivery".to_string(),
2543                op: FilterOp::NotInOrMissing(vec![
2544                    SqlValue::Text("delivered".to_string()),
2545                    SqlValue::Text("failed".to_string()),
2546                ]),
2547                value: SqlValue::Null,
2548            },
2549        ];
2550        if let Some(prefix) = to_prefix {
2551            property_filters.push(PropertyFilter {
2552                json_path: "$.to_actor".to_string(),
2553                op: FilterOp::TextStartsWithIndexed,
2554                value: SqlValue::Text(prefix.to_string()),
2555            });
2556        }
2557        let filter = NoteFilter {
2558            kind: Some("message".to_string()),
2559            property_filters,
2560            ..Default::default()
2561        };
2562        let candidates = self
2563            .notes(token)?
2564            .query_notes_filtered_count_free(
2565                token.namespace().as_str(),
2566                &filter,
2567                PageRequest {
2568                    limit: MAX_SCAN_TOTAL,
2569                    offset: 0,
2570                },
2571            )
2572            .await?
2573            .items;
2574
2575        let mut collected: Vec<khive_storage::note::Note> = Vec::new();
2576        for note in candidates {
2577            let props = note.properties.as_ref().and_then(|v| v.as_object());
2578            let retry_deferred = props
2579                .and_then(|p| p.get("next_attempt_at"))
2580                .and_then(|v| v.as_str())
2581                .and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok())
2582                .is_some_and(|deadline| deadline.timestamp_micros() > now_micros);
2583            if retry_deferred {
2584                continue;
2585            }
2586            collected.push(note);
2587            if collected.len() >= limit as usize {
2588                return Ok(collected);
2589            }
2590        }
2591        Ok(collected)
2592    }
2593
2594    /// Load a live outbound `message` note, returning `InvalidInput`
2595    /// otherwise. Guard shared by the delivery-outcome markers: they take
2596    /// caller-supplied UUIDs, and the generic note patch path would happily
2597    /// stamp delivery properties onto any note kind.
2598    async fn outbound_message(
2599        &self,
2600        token: &NamespaceToken,
2601        id: Uuid,
2602    ) -> RuntimeResult<khive_storage::note::Note> {
2603        let note = self
2604            .notes(token)?
2605            .get_note(id)
2606            .await?
2607            .ok_or_else(|| RuntimeError::NotFound(format!("note {id}")))?;
2608        if note.kind != "message" || note.deleted_at.is_some() {
2609            return Err(RuntimeError::InvalidInput(format!(
2610                "note {id} is not a live message note (kind {})",
2611                note.kind
2612            )));
2613        }
2614        let outbound = note
2615            .properties
2616            .as_ref()
2617            .and_then(|v| v.as_object())
2618            .and_then(|p| p.get("direction"))
2619            .and_then(|v| v.as_str())
2620            == Some("outbound");
2621        if !outbound {
2622            return Err(RuntimeError::InvalidInput(format!(
2623                "note {id} is not an outbound message"
2624            )));
2625        }
2626        Ok(note)
2627    }
2628
2629    async fn replace_outbound_message_properties(
2630        &self,
2631        token: &NamespaceToken,
2632        mut snapshot: khive_storage::note::Note,
2633        properties: serde_json::Map<String, Value>,
2634    ) -> RuntimeResult<khive_storage::note::Note> {
2635        let expected_updated_at = snapshot.updated_at;
2636        let expected_deleted_at = snapshot.deleted_at;
2637        let id = snapshot.id;
2638        snapshot.properties = Some(Value::Object(properties));
2639        snapshot.updated_at = chrono::Utc::now().timestamp_micros().max(
2640            expected_updated_at.checked_add(1).ok_or_else(|| {
2641                RuntimeError::Internal(format!(
2642                    "note {id} updated_at is already at i64::MAX and cannot advance"
2643                ))
2644            })?,
2645        );
2646
2647        // Delivery outcomes preserve transport-owned route fields from the loaded snapshot.
2648        let store = self.raw_notes(token)?;
2649        let persisted = store
2650            .replace_note_if_unchanged(snapshot, expected_updated_at, expected_deleted_at)
2651            .await?;
2652        if !persisted {
2653            return Err(stale_note_snapshot_error(id));
2654        }
2655        // Storage assigns the persisted revision; the pre-write snapshot
2656        // still carries the old version even when this CAS succeeds.
2657        store
2658            .get_note(id)
2659            .await?
2660            .ok_or_else(|| RuntimeError::NotFound(format!("note {id}")))
2661    }
2662
2663    async fn replace_outbound_message_properties_as_owner(
2664        &self,
2665        token: &NamespaceToken,
2666        mut snapshot: khive_storage::note::Note,
2667        properties: serde_json::Map<String, Value>,
2668    ) -> RuntimeResult<khive_storage::note::Note> {
2669        let expected_updated_at = snapshot.updated_at;
2670        let expected_deleted_at = snapshot.deleted_at;
2671        let id = snapshot.id;
2672        snapshot.properties = Some(Value::Object(properties));
2673        snapshot.updated_at = chrono::Utc::now().timestamp_micros().max(
2674            expected_updated_at.checked_add(1).ok_or_else(|| {
2675                RuntimeError::Internal(format!("note {id} updated_at cannot advance"))
2676            })?,
2677        );
2678        // Owner operations preserve any transport evidence on the snapshot.
2679        // Re-running the public full-row guard would reject that existing evidence.
2680        let store = self.raw_notes(token)?;
2681        if !store
2682            .replace_note_if_unchanged(snapshot, expected_updated_at, expected_deleted_at)
2683            .await?
2684        {
2685            return Err(stale_note_snapshot_error(id));
2686        }
2687        // Return the storage-assigned revision, just as the claim path does.
2688        store
2689            .get_note(id)
2690            .await?
2691            .ok_or_else(|| RuntimeError::NotFound(format!("note {id}")))
2692    }
2693
2694    /// True once a `delivery` outcome has been terminally recorded
2695    /// (`"delivered"` or `"failed"`). Shared by every delivery-outcome
2696    /// marker: concurrent outbox workers (two daemon processes overlapping
2697    /// during a restart, per ADR-122 §4/Consequences) can both load the same
2698    /// pending note before either writes, so a marker must re-check the
2699    /// freshly loaded snapshot rather than trust the compare-and-swap alone
2700    /// -- the CAS only rejects a write against a snapshot that has since
2701    /// changed, not a write that starts from an up-to-date terminal snapshot
2702    /// and would otherwise happily overwrite it with a different outcome.
2703    fn outbound_delivery_is_terminal(props: Option<&serde_json::Map<String, Value>>) -> bool {
2704        props
2705            .and_then(|properties| properties.get("delivery"))
2706            .and_then(Value::as_str)
2707            .is_some_and(|state| state == "delivered" || state == "failed")
2708    }
2709
2710    /// Record a transient transport failure while leaving the message
2711    /// pending. The retry deadline is derived from the incremented persisted
2712    /// attempt count, using `base_delay * 2^(attempt - 1)` capped at
2713    /// `max_delay`.
2714    pub async fn mark_outbound_message_transient_failure(
2715        &self,
2716        token: &NamespaceToken,
2717        id: Uuid,
2718        attempted_at: chrono::DateTime<chrono::Utc>,
2719        last_error: String,
2720        base_delay: std::time::Duration,
2721        max_delay: std::time::Duration,
2722    ) -> RuntimeResult<khive_storage::note::Note> {
2723        if base_delay.is_zero() || max_delay < base_delay {
2724            return Err(RuntimeError::InvalidInput(
2725                "outbound retry delays require a non-zero base no greater than the ceiling"
2726                    .to_string(),
2727            ));
2728        }
2729
2730        crate::secret_gate::check_json_at(
2731            &serde_json::json!({
2732                "last_error": &last_error,
2733            }),
2734            "message",
2735            "last_error",
2736        )?;
2737
2738        let snapshot = self.outbound_message(token, id).await?;
2739        let props = snapshot.properties.as_ref().and_then(Value::as_object);
2740        if Self::outbound_delivery_is_terminal(props) {
2741            return Err(RuntimeError::InvalidInput(format!(
2742                "outbound message {id} already has a terminal delivery outcome"
2743            )));
2744        }
2745
2746        let properties = Self::outbound_retry_properties(
2747            props,
2748            attempted_at,
2749            last_error,
2750            base_delay,
2751            max_delay,
2752        )?;
2753        self.replace_outbound_message_properties(token, snapshot, properties)
2754            .await
2755    }
2756
2757    fn outbound_retry_properties(
2758        props: Option<&serde_json::Map<String, Value>>,
2759        attempted_at: chrono::DateTime<chrono::Utc>,
2760        last_error: String,
2761        base_delay: std::time::Duration,
2762        max_delay: std::time::Duration,
2763    ) -> RuntimeResult<serde_json::Map<String, Value>> {
2764        let attempts = props
2765            .and_then(|properties| properties.get("delivery_attempts"))
2766            .and_then(Value::as_u64)
2767            .unwrap_or(0)
2768            .saturating_add(1);
2769        let exponent = attempts.saturating_sub(1).min(127) as u32;
2770        let delay_nanos = base_delay
2771            .as_nanos()
2772            .checked_shl(exponent)
2773            .unwrap_or(u128::MAX)
2774            .min(max_delay.as_nanos());
2775        let delay = std::time::Duration::new(
2776            (delay_nanos / 1_000_000_000) as u64,
2777            (delay_nanos % 1_000_000_000) as u32,
2778        );
2779        let chrono_delay = chrono::TimeDelta::from_std(delay).map_err(|_| {
2780            RuntimeError::InvalidInput("outbound retry ceiling exceeds RFC 3339 range".to_string())
2781        })?;
2782        let next_attempt_at = attempted_at
2783            .checked_add_signed(chrono_delay)
2784            .ok_or_else(|| {
2785                RuntimeError::InvalidInput(
2786                    "outbound retry deadline exceeds RFC 3339 range".to_string(),
2787                )
2788            })?;
2789
2790        let mut properties = props.cloned().unwrap_or_default();
2791        properties.insert("delivery_attempts".to_string(), Value::from(attempts));
2792        properties.insert(
2793            "next_attempt_at".to_string(),
2794            Value::String(next_attempt_at.to_rfc3339()),
2795        );
2796        properties.insert("last_error".to_string(), Value::String(last_error));
2797        Ok(properties)
2798    }
2799
2800    /// Schedule an external-id claim retry only for a still-unclaimed
2801    /// outbound snapshot. Existing claims and terminal outcomes are unchanged.
2802    pub async fn mark_outbound_message_claim_transient_failure(
2803        &self,
2804        token: &NamespaceToken,
2805        id: Uuid,
2806        attempted_at: chrono::DateTime<chrono::Utc>,
2807        last_error: String,
2808        base_delay: std::time::Duration,
2809        max_delay: std::time::Duration,
2810    ) -> RuntimeResult<khive_storage::note::Note> {
2811        let snapshot = self.outbound_message(token, id).await?;
2812        let props = snapshot.properties.as_ref().and_then(Value::as_object);
2813        let has_claim = props
2814            .and_then(|properties| properties.get("external_id"))
2815            .and_then(Value::as_str)
2816            .is_some_and(|value| !value.is_empty());
2817        let has_delivery = props
2818            .and_then(|properties| properties.get("delivered_at"))
2819            .is_some_and(|value| !value.is_null());
2820        if has_claim || has_delivery || Self::outbound_delivery_is_terminal(props) {
2821            return Ok(snapshot);
2822        }
2823        if base_delay.is_zero() || max_delay < base_delay {
2824            return Err(RuntimeError::InvalidInput(
2825                "outbound retry delays require a non-zero base no greater than the ceiling"
2826                    .to_string(),
2827            ));
2828        }
2829        crate::secret_gate::check_json_at(
2830            &serde_json::json!({"last_error": &last_error}),
2831            "message",
2832            "last_error",
2833        )?;
2834        let properties = Self::outbound_retry_properties(
2835            props,
2836            attempted_at,
2837            last_error,
2838            base_delay,
2839            max_delay,
2840        )?;
2841        self.replace_outbound_message_properties_as_owner(token, snapshot, properties)
2842            .await
2843    }
2844
2845    /// Mark an outbound `message` note delivered by merging the ADR-122 §1
2846    /// terminal-outcome properties (`delivery = "delivered"`, `delivered_at`,
2847    /// and `transport_message_id` when the transport minted one), and clearing
2848    /// `delivery_attempts` / `next_attempt_at`. The compare-and-swap protects
2849    /// unrelated properties from a concurrent full-row overwrite;
2850    /// `delivered_at` remains deliberately caller-patchable, pinned by
2851    /// `generic_update_can_still_patch_delivered_at_on_message_note`.
2852    /// Refuses (`InvalidInput`) unless `id` names a live outbound `message`
2853    /// note. Non-wire companion to
2854    /// [`Self::list_undelivered_outbound_messages`] so the delivery loop
2855    /// writes the backend that holds the note.
2856    pub async fn mark_outbound_message_delivered(
2857        &self,
2858        token: &NamespaceToken,
2859        id: Uuid,
2860        delivered_at: String,
2861        transport_message_id: Option<String>,
2862    ) -> RuntimeResult<khive_storage::note::Note> {
2863        crate::secret_gate::check_json_at(
2864            &serde_json::json!({
2865                "delivered_at": &delivered_at,
2866                "transport_message_id": &transport_message_id,
2867            }),
2868            "message",
2869            "delivered",
2870        )?;
2871        let snapshot = self.outbound_message(token, id).await?;
2872        if Self::outbound_delivery_is_terminal(
2873            snapshot.properties.as_ref().and_then(Value::as_object),
2874        ) {
2875            return Err(RuntimeError::InvalidInput(format!(
2876                "outbound message {id} already has a terminal delivery outcome"
2877            )));
2878        }
2879        let mut props = snapshot
2880            .properties
2881            .as_ref()
2882            .and_then(Value::as_object)
2883            .cloned()
2884            .unwrap_or_default();
2885        props.remove("delivery_attempts");
2886        props.remove("next_attempt_at");
2887        props.insert("delivery".into(), Value::String("delivered".into()));
2888        props.insert("delivered_at".into(), Value::String(delivered_at));
2889        if let Some(transport_message_id) = transport_message_id {
2890            props.insert(
2891                "transport_message_id".into(),
2892                Value::String(transport_message_id),
2893            );
2894        }
2895        self.replace_outbound_message_properties(token, snapshot, props)
2896            .await
2897    }
2898
2899    /// Record a permanent delivery failure on an outbound `message` note:
2900    /// `delivery = "failed"`, `failed_at`, `last_error` (ADR-122 §2 — an
2901    /// allowlist rejection must be recorded, not skipped, or the row stays
2902    /// pending forever while the caller saw `ok: true`). Any retry counter and
2903    /// deadline are cleared because the outcome is terminal. Refuses
2904    /// (`InvalidInput`) unless `id` names a live outbound `message` note.
2905    pub async fn mark_outbound_message_failed(
2906        &self,
2907        token: &NamespaceToken,
2908        id: Uuid,
2909        failed_at: String,
2910        last_error: String,
2911    ) -> RuntimeResult<khive_storage::note::Note> {
2912        crate::secret_gate::check_json_at(
2913            &serde_json::json!({
2914                "failed_at": &failed_at,
2915                "last_error": &last_error,
2916            }),
2917            "message",
2918            "failed",
2919        )?;
2920        let snapshot = self.outbound_message(token, id).await?;
2921        if Self::outbound_delivery_is_terminal(
2922            snapshot.properties.as_ref().and_then(Value::as_object),
2923        ) {
2924            return Err(RuntimeError::InvalidInput(format!(
2925                "outbound message {id} already has a terminal delivery outcome"
2926            )));
2927        }
2928        let mut props = snapshot
2929            .properties
2930            .as_ref()
2931            .and_then(Value::as_object)
2932            .cloned()
2933            .unwrap_or_default();
2934        props.remove("delivery_attempts");
2935        props.remove("next_attempt_at");
2936        props.insert("delivery".into(), Value::String("failed".into()));
2937        props.insert("failed_at".into(), Value::String(failed_at));
2938        props.insert("last_error".into(), Value::String(last_error));
2939        self.replace_outbound_message_properties(token, snapshot, props)
2940            .await
2941    }
2942
2943    /// Park a deterministic external-id claim refusal only while the exact
2944    /// current outbound snapshot remains unclaimed. An existing claim or a
2945    /// terminal delivery outcome is returned unchanged. Non-wire owner API:
2946    /// a second worker must not turn another worker's successful claim into
2947    /// a permanent delivery failure.
2948    pub async fn mark_outbound_message_claim_failed(
2949        &self,
2950        token: &NamespaceToken,
2951        id: Uuid,
2952        failed_at: String,
2953        last_error: String,
2954    ) -> RuntimeResult<khive_storage::note::Note> {
2955        let snapshot = self.outbound_message(token, id).await?;
2956        self.mark_outbound_message_claim_failed_from_snapshot(
2957            token, snapshot, failed_at, last_error,
2958        )
2959        .await
2960    }
2961
2962    async fn mark_outbound_message_claim_failed_from_snapshot(
2963        &self,
2964        token: &NamespaceToken,
2965        snapshot: khive_storage::note::Note,
2966        failed_at: String,
2967        last_error: String,
2968    ) -> RuntimeResult<khive_storage::note::Note> {
2969        let props = snapshot.properties.as_ref().and_then(Value::as_object);
2970        let has_claim = props
2971            .and_then(|properties| properties.get("external_id"))
2972            .and_then(Value::as_str)
2973            .is_some_and(|value| !value.is_empty());
2974        let has_delivery = props
2975            .and_then(|properties| properties.get("delivered_at"))
2976            .is_some_and(|value| !value.is_null());
2977        if has_claim || has_delivery || Self::outbound_delivery_is_terminal(props) {
2978            return Ok(snapshot);
2979        }
2980        crate::secret_gate::check_json_at(
2981            &serde_json::json!({ "failed_at": &failed_at, "last_error": &last_error }),
2982            "message",
2983            "failed",
2984        )?;
2985        let mut properties = props.cloned().unwrap_or_default();
2986        properties.remove("delivery_attempts");
2987        properties.remove("next_attempt_at");
2988        properties.insert("delivery".into(), Value::String("failed".into()));
2989        properties.insert("failed_at".into(), Value::String(failed_at));
2990        properties.insert("last_error".into(), Value::String(last_error));
2991        self.replace_outbound_message_properties_as_owner(token, snapshot, properties)
2992            .await
2993    }
2994
2995    /// Merge `from_id` note into `into_id` note.
2996    ///
2997    /// Both notes must exist in the namespace and have the same `kind`. Content is merged
2998    /// per `content_strategy`. Properties are merged per `strategy`. `from_id` is
2999    /// tombstoned (status='deleted', deleted_at set). Returns a summary.
3000    ///
3001    /// If `dry_run` is true, computes and returns the planned summary without mutating
3002    /// any rows, edges, or indexes.
3003    /// The NoteMerged event, including destructive edge preimages, commits in
3004    /// the same SQL transaction as the note and edge changes.
3005    pub async fn merge_note(
3006        &self,
3007        token: &NamespaceToken,
3008        into_id: Uuid,
3009        from_id: Uuid,
3010        strategy: EntityDedupMergePolicy,
3011        content_strategy: ContentMergeStrategy,
3012        dry_run: bool,
3013    ) -> RuntimeResult<MergeSummary> {
3014        self.merge_note_with_reason(
3015            token,
3016            into_id,
3017            from_id,
3018            strategy,
3019            content_strategy,
3020            dry_run,
3021            None,
3022        )
3023        .await
3024    }
3025
3026    /// Merge `from_id` note into `into_id` note and include an optional audit reason.
3027    // REASON: these arguments mirror the merge verb's policy, content strategy,
3028    // dry-run, and audit-reason fields; a builder would only move that surface.
3029    #[allow(clippy::too_many_arguments)]
3030    pub async fn merge_note_with_reason(
3031        &self,
3032        token: &NamespaceToken,
3033        into_id: Uuid,
3034        from_id: Uuid,
3035        strategy: EntityDedupMergePolicy,
3036        content_strategy: ContentMergeStrategy,
3037        dry_run: bool,
3038        reason: Option<String>,
3039    ) -> RuntimeResult<MergeSummary> {
3040        if let Some(reason) = reason.as_deref() {
3041            crate::secret_gate::check_at(reason, "merge", "reason")?;
3042        }
3043        if into_id == from_id {
3044            return Err(RuntimeError::InvalidInput(
3045                "cannot merge a note into itself".into(),
3046            ));
3047        }
3048        let ns = token.namespace().as_str().to_string();
3049        let fts_table = "fts_notes".to_string();
3050        // Keep deletion, table preparation, and survivor reindex on the same
3051        // immutable registry view; see the entity merge path above.
3052        let embedding_plan = EmbeddingModelPlan::capture(self);
3053        let vec_tables = embedding_plan.vector_tables();
3054        let pack_rules = self.pack_edge_rules();
3055
3056        let note_store = self.notes(token)?;
3057        let into_note = note_store
3058            .get_note(into_id)
3059            .await?
3060            .ok_or_else(|| RuntimeError::NotFound("not found in this namespace".into()))?;
3061        Self::ensure_namespace(&into_note.namespace, &ns)?;
3062
3063        let from_note = note_store
3064            .get_note(from_id)
3065            .await?
3066            .ok_or_else(|| RuntimeError::NotFound("not found in this namespace".into()))?;
3067        Self::ensure_namespace(&from_note.namespace, &ns)?;
3068
3069        if !dry_run {
3070            for note in [&into_note, &from_note] {
3071                if let Some(error) = self.stream_member_error(note).await? {
3072                    return Err(error);
3073                }
3074            }
3075        }
3076        reject_pack_managed_schedule_mutation(&into_note, "merge")?;
3077        reject_pack_managed_schedule_mutation(&from_note, "merge")?;
3078
3079        let _ = self.graph(token)?;
3080        let _ = self.text_for_notes(token)?;
3081        let _ = self.events(token)?;
3082        for model_name in embedding_plan.model_names() {
3083            let _ = self.vectors_for_model(token, model_name)?;
3084        }
3085
3086        // Resolved here, where the runtime's installed pack-kind list is in
3087        // reach; `merge_note_sql` runs on the writer connection with no runtime
3088        // handle. Both notes share a kind (checked inside), so the into-note's
3089        // kind decides for the merge.
3090        let preserve_owner_established = self.is_pack_owned_note_kind(&into_note.kind);
3091
3092        let pool = self.backend().pool_arc();
3093        let writer_task = pool
3094            .writer_task_for_runtime_write(RuntimeWriteOperation::MergeNote)
3095            .map_err(RuntimeError::Storage)?;
3096        let event_context = MergeEventContext {
3097            attribution: EventAttribution::from_token(token),
3098            reason,
3099            force: false,
3100            strategy,
3101            content_strategy,
3102            kind: EventKind::NoteMerged,
3103            substrate: SubstrateKind::Note,
3104            event_id: None,
3105        };
3106
3107        let (mut summary, updated_note) = if let Some(writer_task) = writer_task {
3108            writer_task
3109                .send(move |conn| {
3110                    merge_note_sql(
3111                        conn,
3112                        ns,
3113                        fts_table,
3114                        vec_tables,
3115                        into_id,
3116                        from_id,
3117                        strategy,
3118                        content_strategy,
3119                        dry_run,
3120                        pack_rules,
3121                        preserve_owner_established,
3122                        MergeTxLimits::default(),
3123                        Some(event_context),
3124                    )
3125                    .map_err(|e| {
3126                        khive_storage::StorageError::driver(
3127                            khive_storage::StorageCapability::Notes,
3128                            "merge_note",
3129                            e,
3130                        )
3131                    })
3132                })
3133                .await
3134                .map_err(map_merge_note_storage_error)?
3135        } else {
3136            tokio::task::spawn_blocking(move || {
3137                let guard = pool.writer()?;
3138                let mut refusal = None;
3139                let result = guard.transaction(|conn| {
3140                    merge_note_sql(
3141                        conn,
3142                        ns,
3143                        fts_table,
3144                        vec_tables,
3145                        into_id,
3146                        from_id,
3147                        strategy,
3148                        content_strategy,
3149                        dry_run,
3150                        pack_rules,
3151                        preserve_owner_established,
3152                        MergeTxLimits::default(),
3153                        Some(event_context),
3154                    )
3155                    .map_err(|error| match error {
3156                        MergeSqlError::Sqlite(error) => error,
3157                        MergeSqlError::Refusal(error) => {
3158                            refusal = Some(error);
3159                            SqliteError::InvalidData(
3160                                "note merge refused by transactional policy".to_string(),
3161                            )
3162                        }
3163                    })
3164                });
3165                match refusal {
3166                    Some(error) => Err(error),
3167                    None => result.map_err(RuntimeError::from),
3168                }
3169            })
3170            .await
3171            .map_err(|e| RuntimeError::Internal(e.to_string()))??
3172        };
3173
3174        // Count only committed event rows; dry-run never inserts an event.
3175        if !dry_run {
3176            khive_storage::usage::count(khive_storage::usage::UsageUnit::EventRows, 1);
3177            tracing::info!(
3178                into_id = %summary.kept_id,
3179                from_id = %summary.removed_id,
3180                budget_rows = summary.tx_budget.rows_charged,
3181                budget_bytes = summary.tx_budget.bytes_charged,
3182                budget_max_rows = summary.tx_budget.max_rows,
3183                budget_max_bytes = summary.tx_budget.max_bytes,
3184                "merge_note: transaction materialization budget"
3185            );
3186        }
3187
3188        if !dry_run {
3189            if !embedding_plan.is_empty() {
3190                #[cfg(any(test, feature = "fault-injection"))]
3191                let reindex_result =
3192                    if crate::operations::consume_fts_fail_fault(&updated_note.namespace) {
3193                        Err(RuntimeError::Internal("injected FTS failure".to_string()))
3194                    } else {
3195                        self.reindex_note_with_plan(token, &updated_note, &embedding_plan)
3196                            .await
3197                    };
3198                #[cfg(not(any(test, feature = "fault-injection")))]
3199                let reindex_result = self
3200                    .reindex_note_with_plan(token, &updated_note, &embedding_plan)
3201                    .await;
3202
3203                match reindex_result {
3204                    Ok(report) => summary.embedding_truncation = report,
3205                    Err(error) => {
3206                        tracing::warn!(
3207                            into_id = %summary.kept_id,
3208                            from_id = %summary.removed_id,
3209                            error = %error,
3210                            "merge_note: committed merge but survivor reindex failed"
3211                        );
3212                        summary.post_commit_reindex_error = Some(error.to_string());
3213                    }
3214                }
3215            }
3216            // The note row is committed even when no embedding model is
3217            // registered or the post-commit reindex reports an error.
3218            self.fire_note_mutation_hook(&updated_note.kind, updated_note.id)
3219                .await;
3220        }
3221
3222        Ok(summary)
3223    }
3224}
3225
3226/// Keep executable schedule intent behind the schedule pack's state-machine verbs.
3227///
3228/// `scheduled_event` notes carry both replay payloads and lifecycle state. Allowing
3229/// generic note update/merge to rewrite either would turn the immutable creator event
3230/// into a bearer credential for attacker-selected work: replay would attribute the
3231/// changed row to its original creator. Schedule's own transitions use its private
3232/// note-store CAS helpers and therefore do not pass through this generic curation seam.
3233fn reject_pack_managed_schedule_mutation(
3234    note: &khive_storage::note::Note,
3235    operation: &str,
3236) -> RuntimeResult<()> {
3237    if note.kind == "scheduled_event" {
3238        return Err(RuntimeError::InvalidInput(format!(
3239            "cannot {operation} a schedule-managed `scheduled_event` note through generic KG \
3240             mutation; use schedule.cancel or create a replacement schedule"
3241        )));
3242    }
3243    Ok(())
3244}
3245
3246// ---------------------------------------------------------------------------
3247// FTS document construction
3248// ---------------------------------------------------------------------------
3249
3250/// Build the canonical text embedded for an entity on create, update, merge,
3251/// and repair paths.
3252pub fn entity_embedding_text(entity: &Entity) -> String {
3253    match &entity.description {
3254        Some(description) if !description.is_empty() => {
3255            format!("{} {description}", entity.name)
3256        }
3257        _ => entity.name.clone(),
3258    }
3259}
3260
3261/// Build the canonical text embedded for a note when no explicit bounded
3262/// embedding prefix was supplied at creation time.
3263pub fn note_embedding_text(note: &Note) -> String {
3264    note_embedding_text_ref(note).to_owned()
3265}
3266
3267/// Borrow the canonical note embedding text for runtime paths that do not
3268/// require ownership.
3269pub(crate) fn note_embedding_text_ref(note: &Note) -> &str {
3270    &note.content
3271}
3272
3273/// Build the `TextDocument` for an entity. This is the single source of truth for
3274/// entity FTS document shape; all write paths (create, update, merge, reindex, backfill)
3275/// must go through this function so search parity is guaranteed.
3276///
3277/// Body rule: when the entity has a non-empty description, prepend the name
3278/// (`"<name> <description>"`). Otherwise the body is just the name. This
3279/// matches the FTS index contract: `title` and `body` are the ranked columns;
3280/// `tags`, `metadata`, and `namespace` are UNINDEXED.
3281///
3282/// `updated_at` is taken from the entity's own timestamp so that backfill and
3283/// reindex runs record the entity's actual mutation time rather than the
3284/// reindex execution time.
3285pub fn entity_fts_document(entity: &Entity) -> TextDocument {
3286    let updated_at =
3287        chrono::DateTime::from_timestamp_micros(entity.updated_at).unwrap_or_else(chrono::Utc::now);
3288    TextDocument {
3289        subject_id: entity.id,
3290        kind: SubstrateKind::Entity,
3291        record_kind: Some(entity.kind.clone()),
3292        title: Some(entity.name.clone()),
3293        body: entity_embedding_text(entity),
3294        tags: entity.tags.clone(),
3295        namespace: entity.namespace.clone(),
3296        metadata: entity.properties.clone(),
3297        updated_at,
3298    }
3299}
3300
3301/// Build the `TextDocument` for a note. This is the single source of truth for
3302/// note FTS document shape; all write paths (create, update, reindex) must go
3303/// through this function so recall parity is guaranteed. Changes here apply to
3304/// every caller automatically.
3305///
3306/// Body rule: when the note has a `name`, prepend it to the content
3307/// (`"<name> <content>"`). This matches the FTS index contract: title and body
3308/// both contribute to ranking, and the name is the most salient signal.
3309///
3310/// `updated_at` is taken from the note's own timestamp (not `Utc::now()`) so
3311/// that backfill and reindex runs record the note's actual mutation time rather
3312/// than the reindex execution time.
3313pub fn note_fts_document(note: &Note) -> TextDocument {
3314    let body = match &note.name {
3315        Some(n) => format!("{n} {}", note.content),
3316        None => note.content.clone(),
3317    };
3318    let updated_at =
3319        chrono::DateTime::from_timestamp_micros(note.updated_at).unwrap_or_else(chrono::Utc::now);
3320    TextDocument {
3321        subject_id: note.id,
3322        kind: SubstrateKind::Note,
3323        record_kind: Some(note.kind.clone()),
3324        title: note.name.clone(),
3325        body,
3326        tags: vec![],
3327        namespace: note.namespace.clone(),
3328        metadata: note.properties.clone(),
3329        updated_at,
3330    }
3331}
3332
3333/// SQL-bind–ready scalars derived from [`note_fts_document`].
3334///
3335/// Used by `merge_note_sql` to guarantee that the raw SQL FTS INSERT stores
3336/// exactly what [`Fts5TextSearch::upsert_document`] would write, preventing
3337/// null/empty-string divergence on the `title` column for nameless notes.
3338pub(crate) struct NoteFtsScalars {
3339    /// Granular note kind used by the indexed corpus classifier.
3340    pub record_kind: String,
3341    /// Empty string when `note.name` is `None` — matches the `unwrap_or("")` in
3342    /// `Fts5TextSearch::upsert_document`.
3343    pub title: String,
3344    pub body: String,
3345    /// Always the JSON array `"[]"`.
3346    pub tags: String,
3347    /// Serialised `note.properties`, or `None` when properties are absent.
3348    pub metadata: Option<String>,
3349    /// `note.updated_at` converted to `DateTime<Utc>` timestamp_micros.
3350    pub updated_at_micros: i64,
3351}
3352
3353/// Derive [`NoteFtsScalars`] from a [`Note`].
3354///
3355/// All values match the encoding that [`Fts5TextSearch::upsert_document`]
3356/// applies when given the output of [`note_fts_document`].
3357pub(crate) fn note_fts_scalars(note: &Note) -> NoteFtsScalars {
3358    let doc = note_fts_document(note);
3359    NoteFtsScalars {
3360        record_kind: doc.record_kind.unwrap_or_default(),
3361        title: doc.title.unwrap_or_default(),
3362        body: doc.body,
3363        tags: "[]".to_string(),
3364        metadata: doc
3365            .metadata
3366            .as_ref()
3367            .map(|v| serde_json::to_string(v).unwrap_or_default()),
3368        updated_at_micros: doc.updated_at.timestamp_micros(),
3369    }
3370}
3371
3372// ---------------------------------------------------------------------------
3373// Transactional merge SQL helpers
3374// ---------------------------------------------------------------------------
3375
3376/// Cheap SQL-side byte-length probe for one merge entity, evaluated BEFORE
3377/// [`read_merge_entity`] copies its columns into Rust `String`s and parses
3378/// `properties`/`tags` as JSON. `LENGTH()` still requires SQLite to touch the
3379/// stored bytes, but skips the Rust-side allocation and JSON parse — the
3380/// expensive part for an oversized record. Charging this probe against the
3381/// budget before the full read means an over-budget record is rejected
3382/// without ever being materialized or parsed inside the writer transaction.
3383/// Each column is wrapped in `CAST(... AS BLOB)` — plain `LENGTH(text)`
3384/// returns SQLite's *character* count for TEXT values, not the UTF-8 byte
3385/// count the budget is denominated in, so a multibyte (CJK/emoji) record
3386/// could under-report and pass a probe its true byte size exceeds. Casting
3387/// to BLOB forces `LENGTH()` to report octets instead.
3388/// A missing row probes as zero; `read_merge_entity`'s own "not found" error
3389/// fires on the subsequent full read and is unaffected by this probe.
3390fn probe_merge_entity_bytes(conn: &rusqlite::Connection, id: Uuid) -> Result<usize, SqliteError> {
3391    let id_str = id.to_string();
3392    let len: Option<i64> = conn
3393        .query_row(
3394            "SELECT LENGTH(CAST(name AS BLOB)) \
3395                    + COALESCE(LENGTH(CAST(description AS BLOB)), 0) \
3396                    + COALESCE(LENGTH(CAST(properties AS BLOB)), 0) \
3397                    + LENGTH(CAST(tags AS BLOB)) \
3398             FROM entities WHERE id = ?1 AND deleted_at IS NULL",
3399            rusqlite::params![id_str],
3400            |row| row.get(0),
3401        )
3402        .optional()
3403        .map_err(SqliteError::Rusqlite)?;
3404    Ok(128_usize.saturating_add(len.unwrap_or(0).max(0) as usize))
3405}
3406
3407/// Read one entity row by ID within a namespace, returning `SqliteError` on missing/wrong-ns.
3408fn read_merge_entity(
3409    conn: &rusqlite::Connection,
3410    id: Uuid,
3411    namespace: &str,
3412) -> Result<Entity, SqliteError> {
3413    let id_str = id.to_string();
3414    let mut stmt = conn.prepare(
3415        "SELECT id, namespace, kind, entity_type, name, description, properties, tags, \
3416         created_at, updated_at, deleted_at, merged_into, merge_event_id, \
3417         (SELECT a.content_ref FROM attachments AS a \
3418          WHERE a.record_uuid = entities.id AND a.substrate = 'entity' \
3419            AND a.role = 'content') AS content_ref, entities.version \
3420         FROM entities WHERE id = ?1 AND deleted_at IS NULL",
3421    )?;
3422    let mut rows = stmt.query(rusqlite::params![id_str])?;
3423    let row = rows
3424        .next()?
3425        .ok_or_else(|| SqliteError::InvalidData(format!("entity {id} not found")))?;
3426
3427    let id_s: String = row.get(0)?;
3428    let ns: String = row.get(1)?;
3429    let kind: String = row.get(2)?;
3430    let entity_type: Option<String> = row.get(3)?;
3431    let name: String = row.get(4)?;
3432    let description: Option<String> = row.get(5)?;
3433    let properties_str: Option<String> = row.get(6)?;
3434    let tags_str: String = row.get(7)?;
3435    let created_at: i64 = row.get(8)?;
3436    let updated_at: i64 = row.get(9)?;
3437    let deleted_at: Option<i64> = row.get(10)?;
3438    let merged_into_str: Option<String> = row.get(11)?;
3439    let merge_event_id_str: Option<String> = row.get(12)?;
3440    let content_ref: Option<String> = row.get(13)?;
3441    let version: i64 = row.get(14)?;
3442
3443    if ns != namespace {
3444        return Err(SqliteError::InvalidData(format!(
3445            "entity {id} belongs to namespace '{ns}', not '{namespace}'"
3446        )));
3447    }
3448
3449    let entity_id = Uuid::parse_str(&id_s).map_err(|e| SqliteError::InvalidData(e.to_string()))?;
3450    let properties: Option<Value> = properties_str
3451        .map(|s| {
3452            serde_json::from_str::<Value>(&s).map_err(|e| SqliteError::InvalidData(e.to_string()))
3453        })
3454        .transpose()?;
3455    let tags: Vec<String> =
3456        serde_json::from_str(&tags_str).map_err(|e| SqliteError::InvalidData(e.to_string()))?;
3457    let merged_into = merged_into_str
3458        .as_deref()
3459        .map(Uuid::parse_str)
3460        .transpose()
3461        .map_err(|e| SqliteError::InvalidData(e.to_string()))?;
3462    let merge_event_id = merge_event_id_str
3463        .as_deref()
3464        .map(Uuid::parse_str)
3465        .transpose()
3466        .map_err(|e| SqliteError::InvalidData(e.to_string()))?;
3467
3468    Ok(Entity {
3469        id: entity_id,
3470        namespace: ns,
3471        kind,
3472        entity_type,
3473        name,
3474        description,
3475        properties,
3476        tags,
3477        created_at,
3478        updated_at,
3479        version,
3480        deleted_at,
3481        merged_into,
3482        merge_event_id,
3483        content_ref,
3484    })
3485}
3486
3487/// All merge SQL on one connection inside an already-open `BEGIN IMMEDIATE` transaction.
3488///
3489/// Reads both entities, rewires/drops incident edges, merges entity fields, updates FTS,
3490/// deletes the `from` vec entry (if `vec_table` is Some), and tombstones `from` with merge
3491/// provenance.  Returns the updated `into` entity so the caller can do the async vec re-insert.
3492///
3493/// When `dry_run` is true, all reads and computations are performed but no writes are issued.
3494// REASON: merge requires both entity IDs, the namespace, FTS and vec table names, merge
3495// policy, and dry-run flag — all are load-bearing; reducing to a struct would obscure
3496// the sync/async boundary split that keeps this function off the async runtime.
3497#[allow(clippy::too_many_arguments)]
3498fn merge_entity_sql(
3499    conn: &rusqlite::Connection,
3500    namespace: String,
3501    fts_table: String,
3502    vec_tables: Vec<String>,
3503    into_id: Uuid,
3504    from_id: Uuid,
3505    strategy: EntityDedupMergePolicy,
3506    content_strategy: ContentMergeStrategy,
3507    dry_run: bool,
3508    pack_rules: Vec<EdgeEndpointRule>,
3509    validation: EntityMergeValidation,
3510    limits: MergeTxLimits,
3511    merge_event_id: Uuid,
3512    event_context: Option<MergeEventContext>,
3513) -> Result<(MergeSummary, Entity), MergeSqlError> {
3514    let mut budget = MergeTxBudget::new(limits);
3515    // Config-scaled fanout (one FTS/vector delete per table, one contract rule
3516    // set per pack) is charged in bytes only: it is bounded by configuration,
3517    // not by graph shape, but belongs in the same account it amortizes over.
3518    budget.charge(
3519        0,
3520        vec_tables.iter().map(String::len).sum::<usize>()
3521            + pack_rules.len() * std::mem::size_of::<EdgeEndpointRule>(),
3522        "preparing pack and vector fanout",
3523    )?;
3524
3525    budget.charge(
3526        1,
3527        probe_merge_entity_bytes(conn, into_id)?,
3528        "reading merge records",
3529    )?;
3530    let into_entity = read_merge_entity(conn, into_id, &namespace)?;
3531    budget.charge(
3532        1,
3533        probe_merge_entity_bytes(conn, from_id)?,
3534        "reading merge records",
3535    )?;
3536    let from_entity = read_merge_entity(conn, from_id, &namespace)?;
3537
3538    // ADR-115 A1: no production stamp is admitted yet. Check both guarded
3539    // preimages, even when the chosen fold would discard or replace a key.
3540    for properties in [&into_entity.properties, &from_entity.properties] {
3541        crate::secret_gate::reject_reserved_secret_gate_property(properties.as_ref())
3542            .map_err(MergeSqlError::Refusal)?;
3543    }
3544
3545    match validation {
3546        EntityMergeValidation::LegacyKind if into_entity.kind != from_entity.kind => {
3547            return Err(MergeSqlError::Refusal(
3548                EntityMergeRefusal::LegacyKind {
3549                    into_id,
3550                    into_kind: into_entity.kind,
3551                    from_id,
3552                    from_kind: from_entity.kind,
3553                }
3554                .into_runtime_error(),
3555            ));
3556        }
3557        EntityMergeValidation::SafetyFloor => {
3558            validate_entity_merge_floor(&into_entity, &from_entity).map_err(|guard| {
3559                MergeSqlError::Refusal(EntityMergeRefusal::SafetyFloor(guard).into_runtime_error())
3560            })?;
3561        }
3562        EntityMergeValidation::LegacyKind | EntityMergeValidation::Forced => {}
3563    }
3564
3565    // --- Collect edges incident to from_id ---
3566    let parse_id =
3567        |s: String| Uuid::parse_str(&s).map_err(|e| SqliteError::InvalidData(e.to_string()));
3568
3569    let from_str = from_id.to_string();
3570
3571    // Namespace-agnostic (khive#1236): edge endpoints resolve by-ID regardless of
3572    // namespace (ADR-007 Rev 6), and `link` stamps an edge with its *creator's*
3573    // namespace, not either endpoint's — so an edge incident to `from_id` can live
3574    // in any namespace. Scoping this collection to the merge's own namespace missed
3575    // those edges entirely. Each row's own `namespace` column is carried through
3576    // (`EdgeRow::namespace`) and used for every subsequent SQL op against that row.
3577    let mut outbound: Vec<EdgeRow> = Vec::new();
3578    {
3579        let mut stmt = conn.prepare(
3580            "SELECT id, namespace, source_id, target_id, relation, weight, created_at, \
3581                    updated_at, deleted_at, target_backend, metadata \
3582             FROM graph_edges WHERE source_id = ?1",
3583        )?;
3584        let mut rows = stmt.query(rusqlite::params![&from_str])?;
3585        while let Some(row) = rows.next()? {
3586            let edge = EdgeRow {
3587                id: parse_id(row.get(0)?)?,
3588                namespace: row.get(1)?,
3589                source_id: parse_id(row.get(2)?)?,
3590                target_id: parse_id(row.get(3)?)?,
3591                relation: row.get(4)?,
3592                weight: row.get(5)?,
3593                created_at: row.get(6)?,
3594                updated_at: row.get(7)?,
3595                deleted_at: row.get(8)?,
3596                target_backend: row.get(9)?,
3597                metadata: row.get(10)?,
3598            };
3599            budget.charge(1, edge_row_budget_bytes(&edge), "collecting incident edges")?;
3600            outbound.push(edge);
3601        }
3602    }
3603
3604    let mut inbound: Vec<EdgeRow> = Vec::new();
3605    {
3606        let mut stmt = conn.prepare(
3607            "SELECT id, namespace, source_id, target_id, relation, weight, created_at, \
3608                    updated_at, deleted_at, target_backend, metadata \
3609             FROM graph_edges WHERE target_id = ?1",
3610        )?;
3611        let mut rows = stmt.query(rusqlite::params![&from_str])?;
3612        while let Some(row) = rows.next()? {
3613            let edge = EdgeRow {
3614                id: parse_id(row.get(0)?)?,
3615                namespace: row.get(1)?,
3616                source_id: parse_id(row.get(2)?)?,
3617                target_id: parse_id(row.get(3)?)?,
3618                relation: row.get(4)?,
3619                weight: row.get(5)?,
3620                created_at: row.get(6)?,
3621                updated_at: row.get(7)?,
3622                deleted_at: row.get(8)?,
3623                target_backend: row.get(9)?,
3624                metadata: row.get(10)?,
3625            };
3626            budget.charge(1, edge_row_budget_bytes(&edge), "collecting incident edges")?;
3627            inbound.push(edge);
3628        }
3629    }
3630
3631    // Deduplicate by edge ID (a self-edge from_id→from_id appears in both lists).
3632    let mut seen: HashSet<Uuid> = HashSet::new();
3633    let mut all_edges: Vec<EdgeRow> = Vec::new();
3634    for edge in outbound.into_iter().chain(inbound) {
3635        if seen.insert(edge.id) {
3636            all_edges.push(edge);
3637        }
3638    }
3639    let original_edges: HashMap<Uuid, EdgeRow> = all_edges
3640        .iter()
3641        .map(|edge| (edge.id, edge.clone()))
3642        .collect();
3643
3644    // --- Merge entity fields ---
3645    let (merged_props, properties_merged) =
3646        merge_properties(&into_entity.properties, &from_entity.properties, strategy);
3647    crate::secret_gate::reject_reserved_secret_gate_property(merged_props.as_ref())
3648        .map_err(MergeSqlError::Refusal)?;
3649    let merged_name = merge_string_field(&into_entity.name, &from_entity.name, strategy);
3650    let (merged_description, content_appended) = match content_strategy {
3651        ContentMergeStrategy::Append => {
3652            let into_desc = into_entity.description.as_deref().unwrap_or("");
3653            let from_desc = from_entity.description.as_deref().unwrap_or("");
3654            if from_desc.is_empty() {
3655                (into_entity.description.clone(), false)
3656            } else if into_desc.is_empty() {
3657                (from_entity.description.clone(), true)
3658            } else {
3659                (Some(format!("{}\n\n---\n\n{}", into_desc, from_desc)), true)
3660            }
3661        }
3662        // Description selection follows `content_strategy` directly — it is a
3663        // deliberate, independently-settable choice, not derived from the
3664        // entity-field `strategy` (properties/name/tags merge policy).
3665        ContentMergeStrategy::PreferInto => (into_entity.description.clone(), false),
3666        ContentMergeStrategy::PreferFrom => (from_entity.description.clone(), false),
3667    };
3668    let (merged_tags, tags_unioned) = union_tags(&into_entity.tags, &from_entity.tags);
3669
3670    let now = chrono::Utc::now().timestamp_micros();
3671    let into_str = into_id.to_string();
3672    let props_str = merged_props
3673        .as_ref()
3674        .map(|v| serde_json::to_string(v).unwrap_or_default());
3675    let tags_json = serde_json::to_string(&merged_tags).unwrap_or_else(|_| "[]".to_string());
3676
3677    // Writes are gated on `!dry_run` below, but the loop itself always runs so a
3678    // dry-run response reports a predictive `edges_rewired` count instead of zero.
3679    let mut rewired_edge_ids = HashSet::new();
3680    let mut edges_contract_skipped = 0usize;
3681    let mut edge_conflict_preimages = Vec::new();
3682    let mut edges_self_loop_dropped = 0usize;
3683    let mut self_loop_edge_preimages = Vec::new();
3684    let mut conflict_deleted_edge_ids = HashSet::new();
3685    for edge in all_edges {
3686        if conflict_deleted_edge_ids.contains(&edge.id) {
3687            continue;
3688        }
3689        let raw_src = if edge.source_id == from_id {
3690            into_id
3691        } else {
3692            edge.source_id
3693        };
3694        let raw_tgt = if edge.target_id == from_id {
3695            into_id
3696        } else {
3697            edge.target_id
3698        };
3699        let relation_typed = edge.relation.parse::<EdgeRelation>().ok();
3700        // Symmetric relations must be stored with source_uuid < target_uuid.
3701        // Apply canonicalization so the conflict check and UPDATE both use the canonical form.
3702        let (new_src, new_tgt) = match relation_typed {
3703            Some(rel) => canonical_edge_endpoints(rel, raw_src, raw_tgt),
3704            None => (raw_src, raw_tgt),
3705        };
3706
3707        if new_src == new_tgt {
3708            // Capture the preimage unconditionally (dry_run and real runs
3709            // must report the identical count and rows — khive#2934) before
3710            // the write gate below decides whether the DELETE itself runs.
3711            self_loop_edge_preimages.push(edge_row_preimage(&edge)?);
3712            edges_self_loop_dropped += 1;
3713            if !dry_run {
3714                conn.execute(
3715                    "DELETE FROM graph_edges WHERE namespace = ?1 AND id = ?2",
3716                    rusqlite::params![&edge.namespace, edge.id.to_string()],
3717                )?;
3718            }
3719            continue;
3720        }
3721
3722        // Endpoint-contract check (khive#1216): the rewired triple must still pass
3723        // the same allowlist `link` enforces. `into_id` and `from_id` share `kind`
3724        // (enforced by the caller), but `entity_type` may differ between them, so a
3725        // pack rule scoped via `EntityOfType` can accept `from_id`'s edge yet reject
3726        // the post-rewrite pair against `into_id`. A violating edge is dropped and
3727        // counted, mirroring the existing dangling-endpoint skip behavior rather
3728        // than silently writing a contract-violating edge or aborting the merge.
3729        let contract_ok = match relation_typed {
3730            // `annotates` targets may be events or edges, which
3731            // `resolve_merge_edge_endpoint` cannot resolve — evaluate its
3732            // (unconditional) exemption before endpoint resolution so valid
3733            // annotates edges are not dropped as unresolvable.
3734            Some(EdgeRelation::Annotates) => true,
3735            Some(rel) => {
3736                let src_info = if new_src == into_id {
3737                    Some((
3738                        "entity",
3739                        into_entity.kind.clone(),
3740                        into_entity.entity_type.clone(),
3741                    ))
3742                } else {
3743                    resolve_merge_edge_endpoint_budgeted(conn, new_src, &mut budget)?
3744                };
3745                let tgt_info = if new_tgt == into_id {
3746                    Some((
3747                        "entity",
3748                        into_entity.kind.clone(),
3749                        into_entity.entity_type.clone(),
3750                    ))
3751                } else {
3752                    resolve_merge_edge_endpoint_budgeted(conn, new_tgt, &mut budget)?
3753                };
3754                match (src_info, tgt_info) {
3755                    (Some((src_sub, src_kind, src_type)), Some((tgt_sub, tgt_kind, tgt_type))) => {
3756                        merge_rewire_endpoint_contract_allows(
3757                            &pack_rules,
3758                            rel,
3759                            src_sub,
3760                            &src_kind,
3761                            src_type.as_deref(),
3762                            tgt_sub,
3763                            &tgt_kind,
3764                            tgt_type.as_deref(),
3765                        )
3766                    }
3767                    // An endpoint no longer resolves (e.g. concurrently hard-deleted)
3768                    // — cannot evaluate the contract, so drop rather than assume ok.
3769                    _ => false,
3770                }
3771            }
3772            // Relation string predates the closed EdgeRelation enum (pre-migration
3773            // data); leave existing behavior in place rather than guessing.
3774            None => true,
3775        };
3776        if !contract_ok {
3777            if !dry_run {
3778                conn.execute(
3779                    "DELETE FROM graph_edges WHERE namespace = ?1 AND id = ?2",
3780                    rusqlite::params![&edge.namespace, edge.id.to_string()],
3781                )?;
3782            }
3783            tracing::warn!(
3784                edge_id = %edge.id,
3785                source = %new_src,
3786                target = %new_tgt,
3787                relation = %edge.relation,
3788                "merge_entity: dropping rewired edge — endpoint contract violation post-merge"
3789            );
3790            edges_contract_skipped += 1;
3791            continue;
3792        }
3793
3794        let now_ts = chrono::Utc::now().timestamp_micros();
3795        // Preserve the original edge ID where possible so callers can still get()
3796        // it by the ID returned from link(): update in-place when there's no
3797        // conflict; when into_id already owns this (source,target,relation), the
3798        // incoming (from-side) duplicate is dropped and the existing into-edge is
3799        // left untouched (ADR-039 `ON CONFLICT ... DO NOTHING` semantics).
3800        // Check for a conflict: does into_id already have this natural key?
3801        let conflict_id: Option<String> = {
3802            let conflict_src = new_src.to_string();
3803            let conflict_tgt = new_tgt.to_string();
3804            conn.query_row(
3805                khive_db::stores::graph::EDGE_SYMMETRIC_CONFLICT_PROBE_SQL,
3806                rusqlite::params![
3807                    &edge.namespace,
3808                    &conflict_src,
3809                    &conflict_tgt,
3810                    &edge.relation,
3811                    edge.id.to_string(),
3812                ],
3813                |row| row.get(0),
3814            )
3815            .optional()
3816            .map_err(SqliteError::Rusqlite)?
3817        };
3818
3819        if let Some(conflict_id) = conflict_id {
3820            // A live or soft-deleted row already owns this natural key: drop the
3821            // incoming duplicate. The surviving row's weight/metadata/deleted_at
3822            // are never mutated or resurrected. Capture the duplicate and the
3823            // complete hard-delete cascade before removing either, so the audit
3824            // event contains enough state to restore every destroyed row.
3825            let surviving_edge_id = Uuid::parse_str(&conflict_id)
3826                .map_err(|error| SqliteError::InvalidData(error.to_string()))?;
3827            let incident_edge_preimages = collect_conflict_incident_edge_preimages(
3828                conn,
3829                edge.id,
3830                &original_edges,
3831                &mut budget,
3832            )?;
3833            for incident in &incident_edge_preimages {
3834                conflict_deleted_edge_ids.insert(incident.id);
3835                rewired_edge_ids.remove(&incident.id);
3836            }
3837            conflict_deleted_edge_ids.insert(edge.id);
3838            rewired_edge_ids.insert(edge.id);
3839
3840            if !dry_run {
3841                delete_conflict_incident_edges(conn, &incident_edge_preimages)?;
3842                conn.execute(
3843                    khive_db::stores::graph::EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL,
3844                    rusqlite::params![&edge.namespace, edge.id.to_string()],
3845                )?;
3846            }
3847            edge_conflict_preimages.push(MergeEdgeConflictPreimage {
3848                surviving_edge_id,
3849                dropped_edge: edge_row_preimage(&edge)?,
3850                incident_edge_preimages,
3851            });
3852        } else {
3853            if dry_run {
3854                rewired_edge_ids.insert(edge.id);
3855                continue;
3856            }
3857            let changed = conn.execute(
3858                "UPDATE graph_edges SET \
3859                     source_id = ?1, target_id = ?2, updated_at = ?3 \
3860                     WHERE namespace = ?4 AND id = ?5",
3861                rusqlite::params![
3862                    new_src.to_string(),
3863                    new_tgt.to_string(),
3864                    now_ts,
3865                    &edge.namespace,
3866                    edge.id.to_string(),
3867                ],
3868            )?;
3869            if changed > 0 {
3870                rewired_edge_ids.insert(edge.id);
3871            }
3872        }
3873    }
3874    let edges_rewired = rewired_edge_ids.len();
3875
3876    if !dry_run {
3877        // UPDATE only the merged fields — a full-row INSERT OR REPLACE silently
3878        // nulls any column missing from its list (entity_type and the former
3879        // entity-owned content_ref were lost this way; khive#1214). Attachments
3880        // now live in their own table and this targeted UPDATE leaves them alone.
3881        conn.execute(
3882            "UPDATE entities SET version = version + 1, \
3883                 name = ?1, description = ?2, properties = ?3, tags = ?4, \
3884                 updated_at = ?5, merged_into = NULL, merge_event_id = NULL \
3885             WHERE namespace = ?6 AND id = ?7",
3886            rusqlite::params![
3887                &merged_name,
3888                &merged_description,
3889                &props_str,
3890                &tags_json,
3891                now,
3892                &namespace,
3893                &into_str,
3894            ],
3895        )?;
3896
3897        // Body formula mirrors entity_fts_document (the canonical constructor):
3898        // this path is sync/spawn_blocking so it can't call it directly, but
3899        // must stay field-identical.
3900        let fts_body = match &merged_description {
3901            Some(d) if !d.is_empty() => format!("{} {}", merged_name, d),
3902            _ => merged_name.clone(),
3903        };
3904        let kind_str = SubstrateKind::Entity.to_string();
3905        let fts_map = khive_db::stores::text::rowid_map_table(&fts_table);
3906
3907        // `into`'s old FTS row (via the map, not a namespace/subject_id
3908        // scan), then the new merged row, then the map upsert to the new
3909        // rowid. No separate map-row delete first: `INSERT OR REPLACE`
3910        // overwrites it in place (see `delete_document_statement`'s doc
3911        // comment in khive-db).
3912        conn.execute(
3913            &format!(
3914                "DELETE FROM {fts_table} WHERE rowid IN \
3915                 (SELECT rowid FROM {fts_map} WHERE namespace = ?1 AND subject_id = ?2) \
3916                 AND namespace = ?1 AND subject_id = ?2"
3917            ),
3918            rusqlite::params![&namespace, &into_str],
3919        )?;
3920        conn.execute(
3921            &format!(
3922                "INSERT INTO {} \
3923                (subject_id, kind, title, body, tags, namespace, metadata, updated_at, record_kind) \
3924                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
3925                fts_table
3926            ),
3927            rusqlite::params![
3928                &into_str,
3929                &kind_str,
3930                &merged_name,
3931                &fts_body,
3932                &tags_json,
3933                &namespace,
3934                &props_str,
3935                now,
3936                &into_entity.kind,
3937            ],
3938        )?;
3939        conn.execute(
3940            &format!(
3941                "INSERT OR REPLACE INTO {fts_map} (namespace, subject_id, rowid) \
3942                 VALUES (?1, ?2, last_insert_rowid())"
3943            ),
3944            rusqlite::params![&namespace, &into_str],
3945        )?;
3946
3947        // `from`'s FTS row is gone for good (merged away, not reinserted) —
3948        // its map row must be removed too, or it would keep pointing at a
3949        // rowid the DELETE above already reclaimed.
3950        conn.execute(
3951            &format!(
3952                "DELETE FROM {fts_table} WHERE rowid IN \
3953                 (SELECT rowid FROM {fts_map} WHERE namespace = ?1 AND subject_id = ?2) \
3954                 AND namespace = ?1 AND subject_id = ?2"
3955            ),
3956            rusqlite::params![&namespace, &from_str],
3957        )?;
3958        conn.execute(
3959            &format!("DELETE FROM {fts_map} WHERE namespace = ?1 AND subject_id = ?2"),
3960            rusqlite::params![&namespace, &from_str],
3961        )?;
3962
3963        khive_db::stores::vectors::delete_subject_from_vector_tables(
3964            conn,
3965            &vec_tables,
3966            from_id,
3967            &namespace,
3968        )?;
3969
3970        conn.execute(
3971            "UPDATE entities \
3972             SET deleted_at = ?1, merged_into = ?2, merge_event_id = ?3, updated_at = ?1, version = version + 1 \
3973             WHERE namespace = ?4 AND id = ?5 AND deleted_at IS NULL",
3974            rusqlite::params![
3975                now,
3976                into_str,
3977                merge_event_id.to_string(),
3978                &namespace,
3979                &from_str,
3980            ],
3981        )?;
3982    }
3983
3984    let updated_entity = Entity {
3985        id: into_id,
3986        namespace,
3987        kind: into_entity.kind,
3988        entity_type: into_entity.entity_type,
3989        name: merged_name,
3990        description: merged_description,
3991        properties: merged_props,
3992        tags: merged_tags,
3993        created_at: into_entity.created_at,
3994        updated_at: now,
3995        deleted_at: into_entity.deleted_at,
3996        merged_into: None,
3997        merge_event_id: None,
3998        version: if dry_run {
3999            into_entity.version
4000        } else {
4001            into_entity
4002                .version
4003                .checked_add(1)
4004                .ok_or_else(|| SqliteError::InvalidData("entity version overflow".into()))?
4005        },
4006        content_ref: into_entity.content_ref,
4007    };
4008
4009    let summary = MergeSummary {
4010        kept_id: into_id,
4011        removed_id: from_id,
4012        edges_rewired,
4013        edges_self_loop_dropped,
4014        self_loop_edge_preimages,
4015        edges_contract_skipped,
4016        edge_conflict_preimages,
4017        properties_merged,
4018        tags_unioned,
4019        content_appended,
4020        dry_run,
4021        tx_budget: budget.report(),
4022        embedding_truncation: Default::default(),
4023        post_commit_reindex_error: None,
4024    };
4025    // The event is the only durable copy of destructive edge preimages. An
4026    // insertion failure must abort this transaction along with the merge.
4027    if !dry_run {
4028        if let Some(context) = event_context {
4029            append_merge_event_in_transaction(conn, context, &summary, &updated_entity.namespace)?;
4030        }
4031    }
4032    Ok((summary, updated_entity))
4033}
4034
4035// ---------------------------------------------------------------------------
4036// Note merge SQL helpers
4037// ---------------------------------------------------------------------------
4038
4039/// Cheap SQL-side byte-length probe for one merge note — see
4040/// [`probe_merge_entity_bytes`] for why this runs before
4041/// [`read_merge_note`]'s full column copy and JSON parse, and why each
4042/// column is cast to BLOB before `LENGTH()`.
4043fn probe_merge_note_bytes(conn: &rusqlite::Connection, id: Uuid) -> Result<usize, SqliteError> {
4044    let id_str = id.to_string();
4045    let len: Option<i64> = conn
4046        .query_row(
4047            "SELECT COALESCE(LENGTH(CAST(name AS BLOB)), 0) \
4048                    + LENGTH(CAST(content AS BLOB)) \
4049                    + COALESCE(LENGTH(CAST(properties AS BLOB)), 0) \
4050             FROM notes WHERE id = ?1 AND deleted_at IS NULL",
4051            rusqlite::params![id_str],
4052            |row| row.get(0),
4053        )
4054        .optional()
4055        .map_err(SqliteError::Rusqlite)?;
4056    Ok(128_usize.saturating_add(len.unwrap_or(0).max(0) as usize))
4057}
4058
4059/// Read one note row by ID within a namespace, returning `SqliteError` on missing/wrong-ns.
4060fn read_merge_note(
4061    conn: &rusqlite::Connection,
4062    id: Uuid,
4063    namespace: &str,
4064) -> Result<khive_storage::note::Note, SqliteError> {
4065    use khive_storage::note::Note;
4066    let id_str = id.to_string();
4067    let mut stmt = conn.prepare(
4068        "SELECT id, namespace, kind, status, name, content, salience, decay_factor, \
4069         expires_at, properties, created_at, updated_at, deleted_at, key, version \
4070         FROM notes WHERE id = ?1 AND deleted_at IS NULL",
4071    )?;
4072    let mut rows = stmt.query(rusqlite::params![id_str])?;
4073    let row = rows
4074        .next()?
4075        .ok_or_else(|| SqliteError::InvalidData(format!("note {id} not found")))?;
4076
4077    let id_s: String = row.get(0)?;
4078    let ns: String = row.get(1)?;
4079    let kind: String = row.get(2)?;
4080    let status: String = row.get(3)?;
4081    let name: Option<String> = row.get(4)?;
4082    let content: String = row.get(5)?;
4083    let salience: Option<f64> = row.get(6)?;
4084    let decay_factor: Option<f64> = row.get(7)?;
4085    let expires_at: Option<i64> = row.get(8)?;
4086    let properties_str: Option<String> = row.get(9)?;
4087    let created_at: i64 = row.get(10)?;
4088    let updated_at: i64 = row.get(11)?;
4089    let deleted_at: Option<i64> = row.get(12)?;
4090    let key: Option<String> = row.get(13)?;
4091    let version: i64 = row.get(14)?;
4092
4093    if ns != namespace {
4094        return Err(SqliteError::InvalidData(format!(
4095            "note {id} belongs to namespace '{ns}', not '{namespace}'"
4096        )));
4097    }
4098
4099    let note_id = Uuid::parse_str(&id_s).map_err(|e| SqliteError::InvalidData(e.to_string()))?;
4100    let properties: Option<serde_json::Value> = properties_str
4101        .map(|s| serde_json::from_str(&s).map_err(|e| SqliteError::InvalidData(e.to_string())))
4102        .transpose()?;
4103
4104    Ok(Note {
4105        id: note_id,
4106        namespace: ns,
4107        kind,
4108        status,
4109        name,
4110        content,
4111        salience,
4112        decay_factor,
4113        expires_at,
4114        properties,
4115        created_at,
4116        updated_at,
4117        deleted_at,
4118        key,
4119        version,
4120    })
4121}
4122
4123fn max_option_f64(a: Option<f64>, b: Option<f64>) -> Option<f64> {
4124    match (a, b) {
4125        (Some(x), Some(y)) => Some(x.max(y)),
4126        (Some(x), None) => Some(x),
4127        (None, Some(y)) => Some(y),
4128        (None, None) => None,
4129    }
4130}
4131
4132fn append_merge_history(props: Option<Value>, entry: Value) -> Result<Option<Value>, SqliteError> {
4133    use serde_json::{json, Map};
4134    let mut obj: Map<String, Value> = match props {
4135        Some(Value::Object(m)) => m,
4136        Some(other) => {
4137            let mut m = Map::new();
4138            m.insert("_value".into(), other);
4139            m
4140        }
4141        None => Map::new(),
4142    };
4143    let history = obj
4144        .entry("_merge_history".to_string())
4145        .or_insert_with(|| json!([]));
4146    if let Value::Array(arr) = history {
4147        arr.push(entry);
4148    }
4149    Ok(Some(Value::Object(obj)))
4150}
4151
4152/// All note merge SQL on one connection inside a `BEGIN IMMEDIATE` transaction.
4153///
4154/// Reads both notes (must have same `kind`), rewires/drops incident edges, merges content
4155/// per `content_strategy`, tombstones `from`. Returns the updated `into` note for async
4156/// re-embedding.
4157///
4158/// When `dry_run` is true, all reads and computations are performed but no writes are issued.
4159// REASON: note merge additionally requires a content_strategy parameter versus entity merge;
4160// same sync/async boundary rationale as merge_entity_sql applies here.
4161#[allow(clippy::too_many_arguments)]
4162fn merge_note_sql(
4163    conn: &rusqlite::Connection,
4164    namespace: String,
4165    fts_table: String,
4166    vec_tables: Vec<String>,
4167    into_id: Uuid,
4168    from_id: Uuid,
4169    strategy: EntityDedupMergePolicy,
4170    content_strategy: ContentMergeStrategy,
4171    dry_run: bool,
4172    pack_rules: Vec<EdgeEndpointRule>,
4173    preserve_owner_established: bool,
4174    limits: MergeTxLimits,
4175    event_context: Option<MergeEventContext>,
4176) -> Result<(MergeSummary, khive_storage::note::Note), MergeSqlError> {
4177    let mut budget = MergeTxBudget::new(limits);
4178    // Same accounting as `merge_entity_sql`: config-scaled fanout in bytes only.
4179    budget.charge(
4180        0,
4181        vec_tables.iter().map(String::len).sum::<usize>()
4182            + pack_rules.len() * std::mem::size_of::<EdgeEndpointRule>(),
4183        "preparing pack and vector fanout",
4184    )?;
4185
4186    budget.charge(
4187        1,
4188        probe_merge_note_bytes(conn, into_id)?,
4189        "reading merge records",
4190    )?;
4191    let into_note = read_merge_note(conn, into_id, &namespace)?;
4192    budget.charge(
4193        1,
4194        probe_merge_note_bytes(conn, from_id)?,
4195        "reading merge records",
4196    )?;
4197    let from_note = read_merge_note(conn, from_id, &namespace)?;
4198
4199    // Preimages are read in the same guarded unit as the eventual mutation.
4200    // Checking only the fold would allow a stamp to be discarded by a merge.
4201    for properties in [&into_note.properties, &from_note.properties] {
4202        crate::secret_gate::reject_reserved_secret_gate_property(properties.as_ref())
4203            .map_err(MergeSqlError::Refusal)?;
4204    }
4205
4206    if into_note.kind != from_note.kind {
4207        return Err(SqliteError::InvalidData(format!(
4208            "cannot merge notes of different kinds: {} vs {}",
4209            into_note.kind, from_note.kind
4210        ))
4211        .into());
4212    }
4213
4214    // A quarantined message participates in no merges, in either role. Folding
4215    // its content into an ordinary message would retain the body while the
4216    // marker restoration below drops the `quarantined` disposition — laundering
4217    // quarantined transport content into an unmarked record. Release is the
4218    // channel-ingest path's decision, never a side effect of curation.
4219    if into_note.kind == "message"
4220        && (message_is_quarantined(&into_note) || message_is_quarantined(&from_note))
4221    {
4222        return Err(SqliteError::InvalidData(
4223            "cannot merge a quarantined message: quarantine disposition is              transport-owned and must be released by the channel-ingest path              before the content can be folded into another record"
4224                .to_string(),
4225        ).into());
4226    }
4227
4228    let now = chrono::Utc::now().timestamp_micros();
4229    let into_str = into_id.to_string();
4230    let from_str = from_id.to_string();
4231
4232    // Collect edges incident to from_id.
4233    let parse_id =
4234        |s: String| Uuid::parse_str(&s).map_err(|e| SqliteError::InvalidData(e.to_string()));
4235
4236    // Namespace-agnostic (khive#1236): see the equivalent comment in
4237    // `merge_entity_sql` — edge endpoints resolve by-ID regardless of namespace.
4238    let mut outbound: Vec<EdgeRow> = Vec::new();
4239    {
4240        let mut stmt = conn.prepare(
4241            "SELECT id, namespace, source_id, target_id, relation, weight, created_at, updated_at, deleted_at, target_backend, metadata \
4242             FROM graph_edges WHERE source_id = ?1",
4243        )?;
4244        let mut rows = stmt.query(rusqlite::params![&from_str])?;
4245        while let Some(row) = rows.next()? {
4246            let edge = EdgeRow {
4247                id: parse_id(row.get(0)?)?,
4248                namespace: row.get(1)?,
4249                source_id: parse_id(row.get(2)?)?,
4250                target_id: parse_id(row.get(3)?)?,
4251                relation: row.get(4)?,
4252                weight: row.get(5)?,
4253                created_at: row.get(6)?,
4254                updated_at: row.get(7)?,
4255                deleted_at: row.get(8)?,
4256                target_backend: row.get(9)?,
4257                metadata: row.get(10)?,
4258            };
4259            budget.charge(1, edge_row_budget_bytes(&edge), "collecting incident edges")?;
4260            outbound.push(edge);
4261        }
4262    }
4263    let mut inbound: Vec<EdgeRow> = Vec::new();
4264    {
4265        let mut stmt = conn.prepare(
4266            "SELECT id, namespace, source_id, target_id, relation, weight, created_at, updated_at, deleted_at, target_backend, metadata \
4267             FROM graph_edges WHERE target_id = ?1",
4268        )?;
4269        let mut rows = stmt.query(rusqlite::params![&from_str])?;
4270        while let Some(row) = rows.next()? {
4271            let edge = EdgeRow {
4272                id: parse_id(row.get(0)?)?,
4273                namespace: row.get(1)?,
4274                source_id: parse_id(row.get(2)?)?,
4275                target_id: parse_id(row.get(3)?)?,
4276                relation: row.get(4)?,
4277                weight: row.get(5)?,
4278                created_at: row.get(6)?,
4279                updated_at: row.get(7)?,
4280                deleted_at: row.get(8)?,
4281                target_backend: row.get(9)?,
4282                metadata: row.get(10)?,
4283            };
4284            budget.charge(1, edge_row_budget_bytes(&edge), "collecting incident edges")?;
4285            inbound.push(edge);
4286        }
4287    }
4288    let mut seen: HashSet<Uuid> = HashSet::new();
4289    let mut all_edges: Vec<EdgeRow> = Vec::new();
4290    for edge in outbound.into_iter().chain(inbound) {
4291        if seen.insert(edge.id) {
4292            all_edges.push(edge);
4293        }
4294    }
4295    let original_edges: HashMap<Uuid, EdgeRow> = all_edges
4296        .iter()
4297        .map(|edge| (edge.id, edge.clone()))
4298        .collect();
4299
4300    // Merge note fields.
4301    let (merged_content, content_appended) = match content_strategy {
4302        ContentMergeStrategy::Append => {
4303            if from_note.content.is_empty() {
4304                (into_note.content.clone(), false)
4305            } else {
4306                (
4307                    format!("{}\n\n---\n\n{}", into_note.content, from_note.content),
4308                    true,
4309                )
4310            }
4311        }
4312        ContentMergeStrategy::PreferInto => (into_note.content.clone(), false),
4313        ContentMergeStrategy::PreferFrom => (from_note.content.clone(), false),
4314    };
4315
4316    let merged_name = match strategy {
4317        EntityDedupMergePolicy::PreferFrom => from_note.name.clone().or(into_note.name.clone()),
4318        _ => into_note.name.clone().or(from_note.name.clone()),
4319    };
4320
4321    let (mut merged_props, _) =
4322        merge_properties(&into_note.properties, &from_note.properties, strategy);
4323
4324    // A merge folds two records together; it does not transfer attribution.
4325    // On a pack-owned note kind the into-note's owned identity properties are
4326    // restored after the fold, under every strategy including `PreferFrom`, so
4327    // the surviving row still says who wrote it.
4328    if preserve_owner_established {
4329        preserve_owner_established_properties(&into_note.properties, &mut merged_props);
4330    }
4331    preserve_property_keys(
4332        kind_owned_properties(&into_note.kind),
4333        &into_note.properties,
4334        &mut merged_props,
4335    );
4336
4337    // Recomputed from the final retained properties rather than carried
4338    // forward from the fold's own count. The fold's count and post-
4339    // restoration reality diverge whenever an owner-established key holds a
4340    // nested object: `union` recurses into it and counts the absorbed
4341    // note's leaf as merged, but restoration then reverts the whole key,
4342    // and the fold's flat "keys contributed" number cannot express a
4343    // partial reversal of a nested contribution. Diffing the final object
4344    // against the into-note's pre-merge properties sidesteps that fold/
4345    // restoration coupling entirely.
4346    let properties_merged = count_new_property_keys(
4347        into_note.properties.as_ref(),
4348        merged_props.as_ref(),
4349        strategy,
4350    );
4351
4352    let merge_history_entry = serde_json::json!({
4353        "merged_from": from_id.to_string(),
4354        "merged_at": now,
4355        "strategy": format!("{:?}", strategy),
4356        "content_strategy": format!("{:?}", content_strategy),
4357    });
4358    let merged_props = append_merge_history(merged_props, merge_history_entry)?;
4359    crate::secret_gate::reject_reserved_secret_gate_property(merged_props.as_ref())
4360        .map_err(MergeSqlError::Refusal)?;
4361
4362    let merged_salience = max_option_f64(into_note.salience, from_note.salience);
4363    let merged_expires_at = match (into_note.expires_at, from_note.expires_at) {
4364        (Some(a), Some(b)) => Some(a.max(b)),
4365        (Some(a), None) => Some(a),
4366        (None, Some(b)) => Some(b),
4367        (None, None) => None,
4368    };
4369
4370    let props_str = merged_props
4371        .as_ref()
4372        .map(|v| serde_json::to_string(v).unwrap_or_default());
4373
4374    // The loop always runs so a dry-run reports a predictive `edges_rewired`
4375    // count instead of zero (mirrors the entity merge path).
4376    let mut rewired_edge_ids = HashSet::new();
4377    let mut edges_contract_skipped = 0usize;
4378    let mut edge_conflict_preimages = Vec::new();
4379    let mut edges_self_loop_dropped = 0usize;
4380    let mut self_loop_edge_preimages = Vec::new();
4381    let mut conflict_deleted_edge_ids = HashSet::new();
4382    {
4383        for edge in all_edges {
4384            if conflict_deleted_edge_ids.contains(&edge.id) {
4385                continue;
4386            }
4387            let raw_src = if edge.source_id == from_id {
4388                into_id
4389            } else {
4390                edge.source_id
4391            };
4392            let raw_tgt = if edge.target_id == from_id {
4393                into_id
4394            } else {
4395                edge.target_id
4396            };
4397            let relation_typed = edge.relation.parse::<EdgeRelation>().ok();
4398            // Canonicalize symmetric relations before conflict check + UPDATE.
4399            let (new_src, new_tgt) = match relation_typed {
4400                Some(rel) => canonical_edge_endpoints(rel, raw_src, raw_tgt),
4401                None => (raw_src, raw_tgt),
4402            };
4403            if new_src == new_tgt {
4404                // Capture the preimage unconditionally (dry_run and real runs
4405                // must report the identical count and rows — khive#2934)
4406                // before the write gate below decides whether the DELETE
4407                // itself runs.
4408                self_loop_edge_preimages.push(edge_row_preimage(&edge)?);
4409                edges_self_loop_dropped += 1;
4410                if !dry_run {
4411                    conn.execute(
4412                        "DELETE FROM graph_edges WHERE namespace = ?1 AND id = ?2",
4413                        rusqlite::params![&edge.namespace, edge.id.to_string()],
4414                    )?;
4415                }
4416                continue;
4417            }
4418
4419            // Endpoint-contract check (khive#1216/#1236): see the equivalent
4420            // block in `merge_entity_sql` for the full rationale. Here the
4421            // rewiring endpoint is a note (`into_id`'s kind, substrate "note"),
4422            // not an entity.
4423            let contract_ok = match relation_typed {
4424                // Same rationale as the entity-merge path: annotates targets may
4425                // be events or edges, unresolvable by substrate lookup — the
4426                // exemption must precede endpoint resolution.
4427                Some(EdgeRelation::Annotates) => true,
4428                Some(rel) => {
4429                    let src_info = if new_src == into_id {
4430                        Some(("note", into_note.kind.clone(), None))
4431                    } else {
4432                        resolve_merge_edge_endpoint_budgeted(conn, new_src, &mut budget)?
4433                    };
4434                    let tgt_info = if new_tgt == into_id {
4435                        Some(("note", into_note.kind.clone(), None))
4436                    } else {
4437                        resolve_merge_edge_endpoint_budgeted(conn, new_tgt, &mut budget)?
4438                    };
4439                    match (src_info, tgt_info) {
4440                        (
4441                            Some((src_sub, src_kind, src_type)),
4442                            Some((tgt_sub, tgt_kind, tgt_type)),
4443                        ) => merge_rewire_endpoint_contract_allows(
4444                            &pack_rules,
4445                            rel,
4446                            src_sub,
4447                            &src_kind,
4448                            src_type.as_deref(),
4449                            tgt_sub,
4450                            &tgt_kind,
4451                            tgt_type.as_deref(),
4452                        ),
4453                        _ => false,
4454                    }
4455                }
4456                None => true,
4457            };
4458            if !contract_ok {
4459                if !dry_run {
4460                    conn.execute(
4461                        "DELETE FROM graph_edges WHERE namespace = ?1 AND id = ?2",
4462                        rusqlite::params![&edge.namespace, edge.id.to_string()],
4463                    )?;
4464                }
4465                tracing::warn!(
4466                    edge_id = %edge.id,
4467                    source = %new_src,
4468                    target = %new_tgt,
4469                    relation = %edge.relation,
4470                    "merge_note: dropping rewired edge — endpoint contract violation post-merge"
4471                );
4472                edges_contract_skipped += 1;
4473                continue;
4474            }
4475
4476            let now_ts = chrono::Utc::now().timestamp_micros();
4477            let conflict_id: Option<String> = {
4478                let conflict_src = new_src.to_string();
4479                let conflict_tgt = new_tgt.to_string();
4480                conn.query_row(
4481                    khive_db::stores::graph::EDGE_SYMMETRIC_CONFLICT_PROBE_SQL,
4482                    rusqlite::params![
4483                        &edge.namespace,
4484                        &conflict_src,
4485                        &conflict_tgt,
4486                        &edge.relation,
4487                        edge.id.to_string(),
4488                    ],
4489                    |row| row.get(0),
4490                )
4491                .optional()
4492                .map_err(SqliteError::Rusqlite)?
4493            };
4494
4495            if let Some(conflict_id) = conflict_id {
4496                // A live or soft-deleted row already owns this natural key: drop
4497                // the incoming duplicate (ADR-039 `ON CONFLICT ... DO NOTHING`).
4498                // The surviving row's weight/metadata/deleted_at are never
4499                // mutated or resurrected. Match hard `delete_edge`: cascade
4500                // incident annotations, and preserve every removed row first.
4501                let surviving_edge_id = Uuid::parse_str(&conflict_id)
4502                    .map_err(|error| SqliteError::InvalidData(error.to_string()))?;
4503                let incident_edge_preimages = collect_conflict_incident_edge_preimages(
4504                    conn,
4505                    edge.id,
4506                    &original_edges,
4507                    &mut budget,
4508                )?;
4509                for incident in &incident_edge_preimages {
4510                    conflict_deleted_edge_ids.insert(incident.id);
4511                    rewired_edge_ids.remove(&incident.id);
4512                }
4513                conflict_deleted_edge_ids.insert(edge.id);
4514                rewired_edge_ids.insert(edge.id);
4515
4516                if !dry_run {
4517                    delete_conflict_incident_edges(conn, &incident_edge_preimages)?;
4518                    conn.execute(
4519                        khive_db::stores::graph::EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL,
4520                        rusqlite::params![&edge.namespace, edge.id.to_string()],
4521                    )?;
4522                }
4523                edge_conflict_preimages.push(MergeEdgeConflictPreimage {
4524                    surviving_edge_id,
4525                    dropped_edge: edge_row_preimage(&edge)?,
4526                    incident_edge_preimages,
4527                });
4528            } else {
4529                if dry_run {
4530                    rewired_edge_ids.insert(edge.id);
4531                    continue;
4532                }
4533                let changed = conn.execute(
4534                    "UPDATE graph_edges SET \
4535                     source_id = ?1, target_id = ?2, updated_at = ?3 \
4536                     WHERE namespace = ?4 AND id = ?5",
4537                    rusqlite::params![
4538                        new_src.to_string(),
4539                        new_tgt.to_string(),
4540                        now_ts,
4541                        &edge.namespace,
4542                        edge.id.to_string(),
4543                    ],
4544                )?;
4545                if changed > 0 {
4546                    rewired_edge_ids.insert(edge.id);
4547                }
4548            }
4549        }
4550    }
4551    let edges_rewired = rewired_edge_ids.len();
4552
4553    if !dry_run {
4554        conn.prepare_cached(khive_db::stores::note::NOTE_UPSERT_SQL)?
4555            .execute(rusqlite::params![
4556                &into_str,
4557                &namespace,
4558                &into_note.kind,
4559                &into_note.status,
4560                &merged_name,
4561                &merged_content,
4562                merged_salience,
4563                into_note.decay_factor,
4564                merged_expires_at,
4565                &props_str,
4566                into_note.created_at,
4567                now,
4568                into_note.deleted_at,
4569                &into_note.key,
4570            ])?;
4571
4572        let fts_map = khive_db::stores::text::rowid_map_table(&fts_table);
4573
4574        // `into`'s old FTS row (via the map), then the new merged row, then
4575        // the map upsert to the new rowid — see `merge_entity_sql`'s matching
4576        // comment for why no separate map-row delete is needed here.
4577        conn.execute(
4578            &format!(
4579                "DELETE FROM {fts_table} WHERE rowid IN \
4580                 (SELECT rowid FROM {fts_map} WHERE namespace = ?1 AND subject_id = ?2) \
4581                 AND namespace = ?1 AND subject_id = ?2"
4582            ),
4583            rusqlite::params![&namespace, &into_str],
4584        )?;
4585        // Derive FTS scalars through the shared constructor so this raw SQL path
4586        // is field-identical to TextSearch::upsert_document: critically, `title`
4587        // is an empty string (not SQL NULL) for nameless notes, so get_document
4588        // round-trips None <-> "" correctly.
4589        let fts_merged = {
4590            let mut merged_note = Note::new(&namespace, &*into_note.kind, &*merged_content);
4591            merged_note.id = into_id;
4592            merged_note.name = merged_name.clone();
4593            merged_note.properties = merged_props.clone();
4594            merged_note.updated_at = now;
4595            note_fts_scalars(&merged_note)
4596        };
4597        conn.execute(
4598            &format!(
4599                "INSERT INTO {} \
4600                (subject_id, kind, title, body, tags, namespace, metadata, updated_at, record_kind) \
4601                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
4602                fts_table
4603            ),
4604            rusqlite::params![
4605                &into_str,
4606                SubstrateKind::Note.to_string(),
4607                &fts_merged.title,
4608                &fts_merged.body,
4609                &fts_merged.tags,
4610                &namespace,
4611                &fts_merged.metadata,
4612                fts_merged.updated_at_micros,
4613                &fts_merged.record_kind,
4614            ],
4615        )?;
4616        conn.execute(
4617            &format!(
4618                "INSERT OR REPLACE INTO {fts_map} (namespace, subject_id, rowid) \
4619                 VALUES (?1, ?2, last_insert_rowid())"
4620            ),
4621            rusqlite::params![&namespace, &into_str],
4622        )?;
4623
4624        // `from`'s FTS row is gone for good — remove its map row too.
4625        conn.execute(
4626            &format!(
4627                "DELETE FROM {fts_table} WHERE rowid IN \
4628                 (SELECT rowid FROM {fts_map} WHERE namespace = ?1 AND subject_id = ?2) \
4629                 AND namespace = ?1 AND subject_id = ?2"
4630            ),
4631            rusqlite::params![&namespace, &from_str],
4632        )?;
4633        conn.execute(
4634            &format!("DELETE FROM {fts_map} WHERE namespace = ?1 AND subject_id = ?2"),
4635            rusqlite::params![&namespace, &from_str],
4636        )?;
4637
4638        khive_db::stores::vectors::delete_subject_from_vector_tables(
4639            conn,
4640            &vec_tables,
4641            from_id,
4642            &namespace,
4643        )?;
4644
4645        conn.execute(
4646            "UPDATE notes SET status = 'deleted', deleted_at = ?1, updated_at = ?1 \
4647             WHERE namespace = ?2 AND id = ?3 AND deleted_at IS NULL",
4648            rusqlite::params![now, &namespace, &from_str],
4649        )?;
4650    }
4651
4652    let updated_note = khive_storage::note::Note {
4653        id: into_id,
4654        namespace: namespace.clone(),
4655        kind: into_note.kind.clone(),
4656        status: into_note.status.clone(),
4657        name: merged_name,
4658        content: merged_content,
4659        salience: merged_salience,
4660        decay_factor: into_note.decay_factor,
4661        expires_at: merged_expires_at,
4662        properties: merged_props,
4663        created_at: into_note.created_at,
4664        updated_at: now,
4665        deleted_at: into_note.deleted_at,
4666        key: into_note.key.clone(),
4667        version: conn.query_row(
4668            "SELECT version FROM notes WHERE id = ?1",
4669            [&into_str],
4670            |row| row.get(0),
4671        )?,
4672    };
4673
4674    let summary = MergeSummary {
4675        kept_id: into_id,
4676        removed_id: from_id,
4677        edges_rewired,
4678        edges_self_loop_dropped,
4679        self_loop_edge_preimages,
4680        edges_contract_skipped,
4681        edge_conflict_preimages,
4682        properties_merged,
4683        tags_unioned: 0,
4684        content_appended,
4685        dry_run,
4686        tx_budget: budget.report(),
4687        embedding_truncation: Default::default(),
4688        post_commit_reindex_error: None,
4689    };
4690    if !dry_run {
4691        if let Some(context) = event_context {
4692            append_merge_event_in_transaction(conn, context, &summary, &updated_note.namespace)?;
4693        }
4694    }
4695    Ok((summary, updated_note))
4696}
4697
4698// ---------------------------------------------------------------------------
4699// Merge helpers (pure functions — easier to unit test)
4700// ---------------------------------------------------------------------------
4701
4702/// `pub(crate)` so `crate::atomic_prepare::prepare_merge` can reuse this exact
4703/// field-fold semantics for atomic/non-atomic parity.
4704pub(crate) fn merge_string_field(
4705    into: &str,
4706    from: &str,
4707    strategy: EntityDedupMergePolicy,
4708) -> String {
4709    match strategy {
4710        EntityDedupMergePolicy::PreferInto | EntityDedupMergePolicy::Union => into.to_string(),
4711        EntityDedupMergePolicy::PreferFrom => from.to_string(),
4712    }
4713}
4714
4715/// Property keys on a pack-owned note that the owning pack establishes and
4716/// then reads back to decide something structural about the record.
4717///
4718/// The test for membership is that both halves hold: the key is written
4719/// under the owner's authority rather than from caller input, AND its value
4720/// is read to decide identity, grouping, routing, lifecycle, visibility,
4721/// authorization, deduplication, or membership. `from_actor`, `direction` and
4722/// `sent_at` answer "who wrote this, in which direction, when"; `outbound_ref`
4723/// and `thread_id` answer "which record is this one's author-side original,
4724/// and which conversation does it belong to". `subject` is reproduced
4725/// verbatim when a record is re-emitted; `wire_message_id` and `external_id`
4726/// are the author-side citation and correlation key a reply is routed
4727/// against. The set is therefore not "keys that identify a party" — it is
4728/// "keys the owner established and later trusts".
4729///
4730/// Naming one of these in a caller-supplied `properties` patch is refused by
4731/// `update` on a pack-owned kind (see [`owner_established_property_named_in`]).
4732///
4733/// `to_actor` belongs here alongside `from_actor`: comm establishes it at
4734/// send time from the `to=` param, and `comm.read` trusts a present string
4735/// value to decide whether the caller is the addressee, failing open only
4736/// when the key is absent or non-string. A caller must not be able to
4737/// retarget a delivered message's addressee via a patch that names no other
4738/// currently-protected key.
4739///
4740/// Membership here governs writes to an EXISTING record only. Introducing one
4741/// of these keys at create time is a separate question and is not addressed
4742/// by this constant.
4743pub(crate) const OWNER_ESTABLISHED_PROPERTIES: &[&str] = &[
4744    "from_actor",
4745    "to_actor",
4746    "direction",
4747    "sent_at",
4748    "outbound_ref",
4749    "thread_id",
4750    "subject",
4751    "wire_message_id",
4752    "external_id",
4753];
4754
4755/// Kind-specific identity that generic updates cannot patch and merges must
4756/// retain from the surviving record. Message transport evidence belongs to
4757/// `comm.ingest`; health coordinates determine the UUID used by `comm.heartbeat`.
4758/// Unlike OWNER_ESTABLISHED_PROPERTIES, these names remain ordinary metadata
4759/// on other kinds, including tasks and memories.
4760const KIND_OWNED_PROPERTIES: &[(&str, &[&str])] = &[
4761    ("message", &["quarantined", "channel_kind", "channel_slug"]),
4762    ("channel_health", &["channel_kind", "channel_slug"]),
4763];
4764
4765pub(crate) fn kind_owned_properties(kind: &str) -> &'static [&'static str] {
4766    KIND_OWNED_PROPERTIES
4767        .iter()
4768        .find_map(|(owned_kind, keys)| (*owned_kind == kind).then_some(*keys))
4769        .unwrap_or(&[])
4770}
4771
4772/// Whether a stored message note carries a live quarantine disposition.
4773///
4774/// The marker is written by transports as JSON `true` and by some channel
4775/// adapters as the string `"true"`; both spellings are live in stored data
4776/// (`comm.health` counts both). Any present value other than an explicit
4777/// boolean `false` or string `"false"` reads as quarantined, so an unexpected
4778/// encoding fails closed.
4779fn message_is_quarantined(note: &khive_storage::note::Note) -> bool {
4780    let Some(Value::Object(map)) = note.properties.as_ref() else {
4781        return false;
4782    };
4783    match map.get("quarantined") {
4784        None => false,
4785        Some(Value::Bool(value)) => *value,
4786        Some(Value::String(value)) => value != "false",
4787        Some(_) => true,
4788    }
4789}
4790
4791/// The first [`OWNER_ESTABLISHED_PROPERTIES`] key a caller-supplied
4792/// `properties` patch names, if any.
4793///
4794/// Naming a key is the whole test: `update_note` folds the patch with
4795/// `PreferFrom`, so a named key overwrites the stored value and an unnamed one
4796/// leaves it untouched. A non-object patch names nothing.
4797pub(crate) fn owner_established_property_named_in(patch: &Value) -> Option<&'static str> {
4798    let Value::Object(map) = patch else {
4799        return None;
4800    };
4801    OWNER_ESTABLISHED_PROPERTIES
4802        .iter()
4803        .copied()
4804        .find(|key| map.contains_key(*key))
4805}
4806
4807/// Restore the into-note's [`OWNER_ESTABLISHED_PROPERTIES`] into `merged`
4808/// after a property fold.
4809///
4810/// A key absent on the into-note is removed from `merged` rather than left as
4811/// the from-note's value: a record that carried no owner-established value
4812/// must not acquire one by being merged into. That applies to grouping as much
4813/// as to attribution — a note with no `thread_id` must not join a conversation
4814/// because another note was folded into it.
4815///
4816/// A fold can also yield a value that is not an object at all: `merge_json`
4817/// applies a non-object `from` directly under `PreferFrom`, replacing the
4818/// into-note's whole object with a scalar. A scalar cannot carry the
4819/// owner-established keys, so there is nothing to restore them into and they
4820/// would be erased. The into-note's properties are kept instead — the scalar
4821/// contributes no key that could coexist with them, so nothing the fold
4822/// intended is lost.
4823///
4824/// This function only restores values; callers that need to report how many
4825/// properties genuinely survived a merge should diff the final result
4826/// against the into-note's pre-merge properties (see
4827/// [`count_new_property_keys`]) rather than try to track the restoration as
4828/// a correction to the fold's own count — a nested owner-established value
4829/// (an object) makes that correction ill-defined, since the fold's flat
4830/// "keys contributed" number cannot express a partial reversal of a nested
4831/// contribution.
4832pub(crate) fn preserve_owner_established_properties(
4833    into: &Option<Value>,
4834    merged: &mut Option<Value>,
4835) {
4836    preserve_property_keys(OWNER_ESTABLISHED_PROPERTIES, into, merged);
4837}
4838
4839fn preserve_property_keys(keys: &[&str], into: &Option<Value>, merged: &mut Option<Value>) {
4840    if !matches!(merged, Some(Value::Object(_))) {
4841        let Some(Value::Object(into_map)) = into else {
4842            return;
4843        };
4844        let owned_on_into = keys.iter().any(|key| into_map.contains_key(*key));
4845        if owned_on_into {
4846            *merged = into.clone();
4847        }
4848        return;
4849    }
4850    let Some(Value::Object(merged_map)) = merged.as_mut() else {
4851        return;
4852    };
4853    let into_map = match into {
4854        Some(Value::Object(m)) => Some(m),
4855        _ => None,
4856    };
4857    for key in keys {
4858        match into_map.and_then(|m| m.get(*key)) {
4859            Some(value) => {
4860                // Already present on `into` — restore it verbatim.
4861                merged_map.insert((*key).to_string(), value.clone());
4862            }
4863            None => {
4864                // Absent from `into` — a value here came from `from` and
4865                // must not survive the merge.
4866                merged_map.remove(*key);
4867            }
4868        }
4869    }
4870}
4871
4872/// Count properties present in `final_value` that are new relative to
4873/// `original` — the same "did this key actually get added" question
4874/// [`merge_json`]'s fold answers, but computed from what the record finally
4875/// holds rather than carried forward through the fold-then-restore pipeline.
4876///
4877/// A key present in both `original` and `final_value` is never counted, even
4878/// when its value changed — this matches `merge_json`'s own rule that an
4879/// overwrite of a key already present on `into` is not a merged addition.
4880/// Nested objects recurse only when the key exists on both sides (mirroring
4881/// `merge_json`'s `Union` recursion); a key that is wholly new at some level
4882/// counts once for that level, not once per leaf beneath it.
4883pub(crate) fn count_new_property_keys(
4884    original: Option<&Value>,
4885    final_value: Option<&Value>,
4886    strategy: EntityDedupMergePolicy,
4887) -> usize {
4888    match (original, final_value) {
4889        (_, None) => 0,
4890        (None, Some(Value::Object(map))) => map.len(),
4891        (None, Some(_)) => 1,
4892        (Some(Value::Object(orig_map)), Some(Value::Object(final_map))) => {
4893            count_new_keys_within_object(orig_map, final_map, strategy)
4894        }
4895        // The record ended up holding an object where it previously held
4896        // something else. `merge_json` scores that replacement as ONE
4897        // contribution however many keys the new object carries, and this arm
4898        // keeps that rule rather than counting the keys — the alternative
4899        // silently changes `properties_merged` for ordinary notes, which never
4900        // enter the restoration path and were being reported correctly by the
4901        // fold. The rule here is: an empty final object has no contribution
4902        // left to report, whatever emptied it — restoration removing every
4903        // owner-established key is one way that happens, but an ordinary
4904        // `PreferFrom` replacement with an empty object reaches this same arm.
4905        (Some(_), Some(Value::Object(final_map))) => usize::from(!final_map.is_empty()),
4906        // Whole-value replacement by a non-object. `merge_json` scores a
4907        // `PreferFrom` fold that replaces one properties value with a
4908        // differently-shaped one as a single contribution, and that is the right
4909        // answer: what the record now holds came from the from-note. A bare 0
4910        // here would under-report every such replacement, including on note
4911        // kinds that have no owner-established properties and never enter the
4912        // restoration path at all. Equal values mean nothing was contributed,
4913        // which is the `properties: Some(a)` merged with `properties: None`
4914        // case.
4915        (Some(orig), Some(final_val)) => usize::from(orig != final_val),
4916    }
4917}
4918
4919/// Per-key counting inside a properties object.
4920///
4921/// Deliberately NOT the same rule as the top level: within an object, a key that
4922/// already exists and is merely overwritten counts 0, matching `merge_json`'s
4923/// rule that only keys absent from the into-note are counted as added.
4924///
4925/// Recursion is STRATEGY-AWARE, and it has to be, because `merge_json` only
4926/// descends into a same-named nested object under [`Union`]. Under `PreferFrom`
4927/// an existing top-level key is replaced wholesale, and under `PreferInto` it is
4928/// kept wholesale; in neither case is anything merged *beneath* that key, so
4929/// descending here would count a nested value that the fold never treated as a
4930/// separate contribution. Counting `{"meta":{"old":1}}` merged with
4931/// `{"meta":{"new":2}}` under `PreferFrom` as 1 is exactly that mistake — one
4932/// existing property was replaced, none was added.
4933///
4934/// [`Union`]: EntityDedupMergePolicy::Union
4935fn count_new_keys_within_object(
4936    orig_map: &serde_json::Map<String, Value>,
4937    final_map: &serde_json::Map<String, Value>,
4938    strategy: EntityDedupMergePolicy,
4939) -> usize {
4940    final_map
4941        .iter()
4942        .map(|(key, value)| match orig_map.get(key) {
4943            None => 1,
4944            Some(Value::Object(nested_orig))
4945                if matches!(strategy, EntityDedupMergePolicy::Union) =>
4946            {
4947                match value {
4948                    Value::Object(nested_final) => {
4949                        count_new_keys_within_object(nested_orig, nested_final, strategy)
4950                    }
4951                    _ => 0,
4952                }
4953            }
4954            Some(_) => 0,
4955        })
4956        .sum()
4957}
4958
4959/// Merge two property objects. Returns (merged, count_of_fields_from_from_that_were_added).
4960/// `pub(crate)` so `crate::atomic_prepare` can reuse this exact properties-merge
4961/// semantics when building an `update` write plan's row statement, matching
4962/// `update_entity`/`update_note`'s own patch behavior byte-for-byte.
4963pub(crate) fn merge_properties(
4964    into: &Option<Value>,
4965    from: &Option<Value>,
4966    strategy: EntityDedupMergePolicy,
4967) -> (Option<Value>, usize) {
4968    match (into, from) {
4969        (None, None) => (None, 0),
4970        (Some(a), None) => (Some(a.clone()), 0),
4971        (None, Some(b)) => {
4972            let count = if let Value::Object(m) = b { m.len() } else { 1 };
4973            (Some(b.clone()), count)
4974        }
4975        (Some(into_val), Some(from_val)) => {
4976            let (merged, added) = merge_json(into_val, from_val, strategy);
4977            (Some(merged), added)
4978        }
4979    }
4980}
4981
4982/// Compare note-update values using the semantics exposed by note readers.
4983/// `serde_json::Value` already compares objects without depending on insertion
4984/// order; the top-level `properties.tags` array is compared as an
4985/// order-independent multiset because readers treat it as a set while
4986/// preserving duplicate entries as a meaningful representation change.
4987fn note_update_values_equal(left: &Option<Value>, right: &Option<Value>) -> bool {
4988    fn equal(left: &Value, right: &Value, is_tags_field: bool, is_properties_object: bool) -> bool {
4989        match (left, right) {
4990            (Value::Object(a), Value::Object(b)) => {
4991                a.len() == b.len()
4992                    && a.iter().all(|(key, value)| {
4993                        b.get(key).is_some_and(|other| {
4994                            equal(value, other, is_properties_object && key == "tags", false)
4995                        })
4996                    })
4997            }
4998            (Value::Array(a), Value::Array(b)) if is_tags_field => {
4999                if a.len() != b.len() {
5000                    return false;
5001                }
5002                let mut left = a.iter().map(Value::to_string).collect::<Vec<_>>();
5003                let mut right = b.iter().map(Value::to_string).collect::<Vec<_>>();
5004                left.sort_unstable();
5005                right.sort_unstable();
5006                left == right
5007            }
5008            (Value::Array(a), Value::Array(b)) => {
5009                a.len() == b.len()
5010                    && a.iter()
5011                        .zip(b)
5012                        .all(|(left, right)| equal(left, right, false, false))
5013            }
5014            _ => left == right,
5015        }
5016    }
5017
5018    match (left, right) {
5019        (None, None) => true,
5020        (Some(left), Some(right)) => equal(left, right, false, true),
5021        _ => false,
5022    }
5023}
5024
5025/// Deep-merge two JSON values per strategy. Returns (merged, keys_contributed_by_from).
5026fn merge_json(into: &Value, from: &Value, strategy: EntityDedupMergePolicy) -> (Value, usize) {
5027    match (into, from, strategy) {
5028        (Value::Object(a), Value::Object(b), EntityDedupMergePolicy::Union) => {
5029            let mut result = a.clone();
5030            let mut added = 0usize;
5031            for (k, v_from) in b {
5032                if let Some(v_into) = a.get(k) {
5033                    let (merged, sub_added) =
5034                        merge_json(v_into, v_from, EntityDedupMergePolicy::Union);
5035                    result.insert(k.clone(), merged);
5036                    added += sub_added;
5037                } else {
5038                    result.insert(k.clone(), v_from.clone());
5039                    added += 1;
5040                }
5041            }
5042            (Value::Object(result), added)
5043        }
5044        (Value::Object(a), Value::Object(b), EntityDedupMergePolicy::PreferInto) => {
5045            let mut result = a.clone();
5046            let mut added = 0usize;
5047            for (k, v) in b {
5048                if !a.contains_key(k) {
5049                    result.insert(k.clone(), v.clone());
5050                    added += 1;
5051                }
5052            }
5053            (Value::Object(result), added)
5054        }
5055        (Value::Object(a), Value::Object(b), EntityDedupMergePolicy::PreferFrom) => {
5056            let mut result = a.clone();
5057            let mut added = 0usize;
5058            for (k, v) in b {
5059                result.insert(k.clone(), v.clone());
5060                if !a.contains_key(k) {
5061                    added += 1;
5062                }
5063            }
5064            (Value::Object(result), added)
5065        }
5066        // Non-object scalars: apply strategy directly.
5067        (_into_val, from_val, EntityDedupMergePolicy::PreferFrom) => (from_val.clone(), 1),
5068        _ => (into.clone(), 0),
5069    }
5070}
5071
5072/// `pub(crate)` so `crate::atomic_prepare::prepare_merge` can reuse this for
5073/// atomic/non-atomic parity.
5074pub(crate) fn union_tags(into: &[String], from: &[String]) -> (Vec<String>, usize) {
5075    let mut seen: HashSet<&str> = into.iter().map(|s| s.as_str()).collect();
5076    let mut result: Vec<String> = into.to_vec();
5077    let mut added = 0usize;
5078    for tag in from {
5079        if seen.insert(tag.as_str()) {
5080            result.push(tag.clone());
5081            added += 1;
5082        }
5083    }
5084    (result, added)
5085}
5086
5087// ---------------------------------------------------------------------------
5088// INLINE TEST JUSTIFICATION: tests here exercise patch/merge helpers and the
5089// update_note/update_entity paths that share private merge_properties logic.
5090// Moving them to tests/ would require pub-exporting merge_properties, which is
5091// an internal invariant not suitable for the public API surface. Broad
5092// behavioral curation tests live in tests/integration.rs.
5093// ---------------------------------------------------------------------------
5094
5095#[cfg(test)]
5096mod merge_reservation_tests;
5097
5098#[cfg(test)]
5099mod tests {
5100    use std::sync::Arc;
5101    use std::sync::Mutex;
5102
5103    use super::*;
5104    use crate::runtime::{KhiveRuntime, NamespaceToken};
5105    use khive_storage::types::{Direction, TextFilter, TextQueryMode, TextSearchRequest};
5106    use khive_types::EndpointKind;
5107
5108    fn rt() -> KhiveRuntime {
5109        KhiveRuntime::memory().unwrap()
5110    }
5111
5112    fn set_merge_event_refusal(rt: &KhiveRuntime, kind: &str, refuse: bool) {
5113        let pool = rt.backend().pool_arc();
5114        let guard = pool.writer().expect("acquire fixture writer");
5115        let sql = if refuse {
5116            format!(
5117                "CREATE TRIGGER reject_merge_event BEFORE INSERT ON events \
5118                 WHEN NEW.kind = '{kind}' BEGIN SELECT RAISE(ABORT, 'blocked merge event'); END"
5119            )
5120        } else {
5121            "DROP TRIGGER reject_merge_event".to_string()
5122        };
5123        guard.conn().execute_batch(&sql).expect("set event fault");
5124    }
5125
5126    async fn seed_health_identity_note(runtime: &KhiveRuntime, slug: &str) -> Note {
5127        let mut note = Note::new("local", "channel_health", "health state");
5128        note.properties = Some(serde_json::json!({
5129            "channel_kind": "email", "channel_slug": slug, "operator_note": "original"
5130        }));
5131        runtime
5132            .notes(&NamespaceToken::local())
5133            .unwrap()
5134            .upsert_note(note.clone())
5135            .await
5136            .unwrap();
5137        note
5138    }
5139
5140    /// Must fail without the runtime coordinate guard: the first plain update
5141    /// succeeds and changes channel_kind even though no pack hook ran.
5142    #[tokio::test]
5143    async fn channel_health_identity_plain_update_refuses_coordinate_changes() {
5144        let runtime = rt();
5145        let token = NamespaceToken::local();
5146        let note = seed_health_identity_note(&runtime, "original@example.com").await;
5147        let before = serde_json::to_value(&note).unwrap();
5148        for properties in [
5149            serde_json::json!({"channel_kind": "telegram", "operator_note": "changed"}),
5150            serde_json::json!({"channel_slug": "other@example.com"}),
5151            serde_json::json!({"channel_kind": null}),
5152            serde_json::json!({"channel_slug": null}),
5153            serde_json::json!({"channel_kind": "email"}),
5154            serde_json::json!({"channel_slug": "original@example.com"}),
5155            serde_json::json!(null),
5156            serde_json::json!([]),
5157        ] {
5158            let error = runtime
5159                .update_note(
5160                    &token,
5161                    note.id,
5162                    NotePatch::new(None, None, None, None, Some(properties)),
5163                )
5164                .await
5165                .expect_err("plain runtime update must preserve heartbeat coordinates");
5166            assert!(error.to_string().contains("channel_health"), "{error}");
5167            let stored = runtime
5168                .notes(&token)
5169                .unwrap()
5170                .get_note(note.id)
5171                .await
5172                .unwrap()
5173                .unwrap();
5174            assert_eq!(serde_json::to_value(stored).unwrap(), before);
5175        }
5176        let changed = runtime
5177            .update_note(
5178                &token,
5179                note.id,
5180                NotePatch::new(
5181                    None,
5182                    None,
5183                    None,
5184                    None,
5185                    Some(serde_json::json!({"operator_note": "changed"})),
5186                ),
5187            )
5188            .await
5189            .unwrap();
5190        let properties = changed.properties.unwrap();
5191        assert_eq!(properties["channel_kind"], "email");
5192        assert_eq!(properties["channel_slug"], "original@example.com");
5193        assert_eq!(properties["operator_note"], "changed");
5194    }
5195
5196    #[tokio::test]
5197    async fn channel_health_identity_atomic_prepare_refuses_coordinates() {
5198        let runtime = rt();
5199        let token = NamespaceToken::local();
5200        let note = seed_health_identity_note(&runtime, "original@example.com").await;
5201        let error = crate::atomic_prepare::prepare_update(
5202            &runtime,
5203            &token,
5204            &serde_json::json!({
5205                "id": note.id.to_string(),
5206                "properties": {"channel_slug": "other@example.com"}
5207            }),
5208            None,
5209        )
5210        .await
5211        .expect_err("atomic/proposal preparation must use the runtime identity guard");
5212        assert!(error.to_string().contains("channel_slug"), "{error}");
5213        let stored = runtime
5214            .notes(&token)
5215            .unwrap()
5216            .get_note(note.id)
5217            .await
5218            .unwrap()
5219            .unwrap();
5220        assert_eq!(
5221            serde_json::to_value(stored).unwrap(),
5222            serde_json::to_value(note).unwrap()
5223        );
5224    }
5225
5226    /// Must fail without merge restoration: PreferFrom replaces the surviving
5227    /// row's coordinates with the absorbed row's values.
5228    #[tokio::test]
5229    async fn channel_health_identity_merge_retains_survivor_coordinates() {
5230        for strategy in [
5231            EntityDedupMergePolicy::PreferFrom,
5232            EntityDedupMergePolicy::Union,
5233            EntityDedupMergePolicy::PreferInto,
5234        ] {
5235            let runtime = rt();
5236            let token = NamespaceToken::local();
5237            let into = seed_health_identity_note(&runtime, "survivor@example.com").await;
5238            let mut from = seed_health_identity_note(&runtime, "absorbed@example.com").await;
5239            from.properties = Some(serde_json::json!({
5240                "channel_kind": "telegram", "channel_slug": "absorbed@example.com", "new_metadata": true
5241            }));
5242            // Trusted fixture setup establishes a distinct source identity;
5243            // the public store must refuse changing an existing health row.
5244            runtime
5245                .raw_notes(&token)
5246                .unwrap()
5247                .upsert_note(from.clone())
5248                .await
5249                .unwrap();
5250            runtime
5251                .merge_note(
5252                    &token,
5253                    into.id,
5254                    from.id,
5255                    strategy,
5256                    ContentMergeStrategy::PreferInto,
5257                    false,
5258                )
5259                .await
5260                .unwrap();
5261            let stored = runtime
5262                .notes(&token)
5263                .unwrap()
5264                .get_note(into.id)
5265                .await
5266                .unwrap()
5267                .unwrap();
5268            assert_eq!(stored.id, into.id);
5269            assert_eq!(stored.created_at, into.created_at);
5270            let properties = stored.properties.unwrap();
5271            assert_eq!(properties["channel_kind"], "email");
5272            assert_eq!(properties["channel_slug"], "survivor@example.com");
5273            assert_eq!(properties["new_metadata"], true);
5274            assert!(runtime
5275                .notes(&token)
5276                .unwrap()
5277                .get_note(from.id)
5278                .await
5279                .unwrap()
5280                .is_none());
5281        }
5282    }
5283
5284    async fn entity_update_events(
5285        runtime: &KhiveRuntime,
5286        token: &NamespaceToken,
5287    ) -> Vec<khive_storage::event::Event> {
5288        runtime
5289            .events(token)
5290            .unwrap()
5291            .query_events(
5292                khive_storage::event::EventFilter {
5293                    kinds: vec![EventKind::EntityUpdated],
5294                    ..Default::default()
5295                },
5296                khive_storage::types::PageRequest {
5297                    limit: 100,
5298                    offset: 0,
5299                },
5300            )
5301            .await
5302            .unwrap()
5303            .items
5304    }
5305
5306    fn outbound_message_note() -> Note {
5307        let mut note = Note::new("local", "message", "hello");
5308        note.properties = Some(serde_json::json!({"direction": "outbound"}));
5309        note
5310    }
5311
5312    /// Predicate + ordering contract of the non-wire outbox scan: outbound
5313    /// with absent OR explicitly-null `delivered_at` is undelivered; a
5314    /// non-null `delivered_at`, a terminal `delivery` state, an inbound row,
5315    /// and a soft-deleted row are all excluded; results come newest-first
5316    /// and respect `limit`; `limit=0` returns nothing.
5317    #[tokio::test]
5318    async fn list_undelivered_outbound_messages_predicate_and_order() {
5319        let rt = rt();
5320        rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
5321        let tok = NamespaceToken::local();
5322        let store = rt.notes(&tok).expect("note store");
5323
5324        let mut undelivered_old = outbound_message_note();
5325        undelivered_old.created_at -= 10;
5326        let mut undelivered_null = outbound_message_note();
5327        undelivered_null.properties =
5328            Some(serde_json::json!({"direction": "outbound", "delivered_at": null}));
5329        let mut delivered = outbound_message_note();
5330        delivered.properties = Some(
5331            serde_json::json!({"direction": "outbound", "delivered_at": "2026-08-28T00:00:00Z"}),
5332        );
5333        // ADR-122 terminal states without `delivered_at` are not pending.
5334        let mut terminal_failed = outbound_message_note();
5335        terminal_failed.properties = Some(
5336            serde_json::json!({"direction": "outbound", "delivery": "failed", "last_error": "x"}),
5337        );
5338        let mut inbound = Note::new("local", "message", "inbound row");
5339        inbound.properties = Some(serde_json::json!({"direction": "inbound"}));
5340        let mut soft_deleted = outbound_message_note();
5341        soft_deleted.deleted_at = Some(chrono::Utc::now().timestamp_micros());
5342
5343        let old_id = undelivered_old.id;
5344        let null_id = undelivered_null.id;
5345        for note in [
5346            undelivered_old,
5347            undelivered_null,
5348            delivered,
5349            terminal_failed,
5350            inbound,
5351            soft_deleted,
5352        ] {
5353            store.upsert_note(note).await.expect("seed note");
5354        }
5355
5356        let hits = rt
5357            .list_undelivered_outbound_messages(&tok, None, 200)
5358            .await
5359            .expect("scan succeeds");
5360        let ids: Vec<_> = hits.iter().map(|n| n.id).collect();
5361        assert_eq!(
5362            ids,
5363            vec![null_id, old_id],
5364            "only the two undelivered outbound rows, newest-first"
5365        );
5366
5367        let capped = rt
5368            .list_undelivered_outbound_messages(&tok, None, 1)
5369            .await
5370            .expect("capped scan succeeds");
5371        assert_eq!(
5372            capped.iter().map(|n| n.id).collect::<Vec<_>>(),
5373            vec![null_id],
5374            "limit truncates after the newest undelivered row"
5375        );
5376
5377        let zero = rt
5378            .list_undelivered_outbound_messages(&tok, None, 0)
5379            .await
5380            .expect("zero-limit scan succeeds");
5381        assert!(zero.is_empty(), "limit=0 returns no rows, not one");
5382    }
5383
5384    /// Regression guard for the scan window (#1859). The pending predicate
5385    /// admits every actor-to-actor outbound row forever (nothing marks them
5386    /// delivered), so the pending population outgrows any scan cap. With
5387    /// more such rows than the cap, an email row that sorts past the cap in
5388    /// every candidate index order (older `created_at`, later rowid, a
5389    /// `to_actor` that collates after the fillers') must still be returned:
5390    /// the channel prefix has to bound the SQL candidate set, not trim it
5391    /// afterwards. `list_undelivered_outbound_messages_matches_legacy_predicate`
5392    /// is the under-cap control.
5393    #[tokio::test]
5394    async fn list_undelivered_outbound_messages_email_row_past_the_scan_window() {
5395        const FILLERS: usize = 10_050;
5396        let rt = rt();
5397        rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
5398        let tok = NamespaceToken::local();
5399        let store = rt.notes(&tok).expect("note store");
5400
5401        let mut email = outbound_message_note();
5402        email.created_at -= 1_000_000;
5403        email.updated_at = email.created_at;
5404        email.properties = Some(
5405            serde_json::json!({"direction": "outbound", "to_actor": "email:ocean@example.test"}),
5406        );
5407        let email_id = email.id;
5408
5409        let mut fillers = Vec::with_capacity(FILLERS);
5410        for i in 0..FILLERS {
5411            let mut filler = outbound_message_note();
5412            filler.created_at += i as i64;
5413            filler.updated_at = filler.created_at;
5414            filler.properties =
5415                Some(serde_json::json!({"direction": "outbound", "to_actor": "daemon:filler"}));
5416            fillers.push(filler);
5417        }
5418        // Fillers first so the email row also takes the later rowid.
5419        let summary = store.upsert_notes(fillers).await.expect("seed fillers");
5420        assert_eq!(
5421            summary.affected as usize, FILLERS,
5422            "every filler row seeded"
5423        );
5424        store.upsert_note(email).await.expect("seed email row");
5425
5426        let hits = rt
5427            .list_undelivered_outbound_messages(&tok, Some("email:"), 200)
5428            .await
5429            .expect("scan succeeds");
5430        assert_eq!(
5431            hits.iter().map(|n| n.id).collect::<Vec<_>>(),
5432            vec![email_id],
5433            "the email row is found behind {FILLERS} pending actor-to-actor rows"
5434        );
5435
5436        let daemon_hits = rt
5437            .list_undelivered_outbound_messages(&tok, Some("daemon:"), 3)
5438            .await
5439            .expect("scan succeeds");
5440        assert_eq!(
5441            daemon_hits.len(),
5442            3,
5443            "the other prefix still sees its own rows"
5444        );
5445    }
5446
5447    fn legacy_outbox_pending(note: &Note, to_prefix: Option<&str>, now_micros: i64) -> bool {
5448        if note.deleted_at.is_some() {
5449            return false;
5450        }
5451        let props = note.properties.as_ref().and_then(|value| value.as_object());
5452        if props
5453            .and_then(|properties| properties.get("direction"))
5454            .and_then(Value::as_str)
5455            != Some("outbound")
5456        {
5457            return false;
5458        }
5459        if let Some(prefix) = to_prefix {
5460            let matches = props
5461                .and_then(|properties| properties.get("to_actor"))
5462                .and_then(Value::as_str)
5463                .is_some_and(|actor| actor.starts_with(prefix));
5464            if !matches {
5465                return false;
5466            }
5467        }
5468        if props
5469            .and_then(|properties| properties.get("delivered_at"))
5470            .is_some_and(|value| !value.is_null())
5471        {
5472            return false;
5473        }
5474        if props
5475            .and_then(|properties| properties.get("delivery"))
5476            .and_then(Value::as_str)
5477            .is_some_and(|state| state == "delivered" || state == "failed")
5478        {
5479            return false;
5480        }
5481        let retry_deferred = props
5482            .and_then(|properties| properties.get("next_attempt_at"))
5483            .and_then(Value::as_str)
5484            .and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok())
5485            .is_some_and(|deadline| deadline.timestamp_micros() > now_micros);
5486        !retry_deferred
5487    }
5488
5489    /// The SQL-prefiltered scan must return exactly what the former full-note
5490    /// scan selected, including every legacy and channel-partition edge case.
5491    #[tokio::test]
5492    async fn list_undelivered_outbound_messages_matches_legacy_predicate() {
5493        let rt = rt();
5494        rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
5495        let tok = NamespaceToken::local();
5496        let store = rt.notes(&tok).expect("note store");
5497
5498        let make_note = |created_at, properties, deleted_at| {
5499            let mut note = Note::new("local", "message", "outbox fixture");
5500            note.created_at = created_at;
5501            note.updated_at = created_at;
5502            note.properties = Some(properties);
5503            note.deleted_at = deleted_at;
5504            note
5505        };
5506        let notes = vec![
5507            make_note(
5508                110,
5509                serde_json::json!({"direction": "outbound", "to_actor": "email:absent"}),
5510                None,
5511            ),
5512            make_note(
5513                109,
5514                serde_json::json!({
5515                    "direction": "outbound",
5516                    "to_actor": "email:null",
5517                    "delivered_at": null
5518                }),
5519                None,
5520            ),
5521            make_note(
5522                108,
5523                serde_json::json!({
5524                    "direction": "outbound",
5525                    "to_actor": "email:terminal-failed",
5526                    "delivery": "failed"
5527                }),
5528                None,
5529            ),
5530            make_note(
5531                107,
5532                serde_json::json!({
5533                    "direction": "outbound",
5534                    "to_actor": "email:terminal-delivered",
5535                    "delivery": "delivered"
5536                }),
5537                None,
5538            ),
5539            make_note(
5540                106,
5541                serde_json::json!({
5542                    "direction": "outbound",
5543                    "to_actor": "email:malformed",
5544                    "next_attempt_at": "not-a-timestamp"
5545                }),
5546                None,
5547            ),
5548            make_note(
5549                105,
5550                serde_json::json!({
5551                    "direction": "outbound",
5552                    "to_actor": "email:future",
5553                    "next_attempt_at": "2999-01-01T00:00:00Z"
5554                }),
5555                None,
5556            ),
5557            make_note(
5558                104,
5559                serde_json::json!({
5560                    "direction": "outbound",
5561                    "to_actor": "email:due",
5562                    "next_attempt_at": "2000-01-01T00:00:00Z"
5563                }),
5564                None,
5565            ),
5566            make_note(
5567                103,
5568                serde_json::json!({"direction": "inbound", "to_actor": "email:inbound"}),
5569                None,
5570            ),
5571            make_note(
5572                102,
5573                serde_json::json!({"direction": "outbound", "to_actor": "telegram:other"}),
5574                None,
5575            ),
5576            make_note(
5577                101,
5578                serde_json::json!({
5579                    "direction": "outbound",
5580                    "to_actor": "email:already-delivered",
5581                    "delivered_at": "2026-08-28T00:00:00Z"
5582                }),
5583                None,
5584            ),
5585            make_note(
5586                100,
5587                serde_json::json!({"direction": "outbound", "to_actor": "email:deleted"}),
5588                Some(100),
5589            ),
5590        ];
5591        for note in &notes {
5592            store.upsert_note(note.clone()).await.expect("seed note");
5593        }
5594
5595        let now_micros = chrono::Utc::now().timestamp_micros();
5596        let all_rows = rt
5597            .list_notes(&tok, Some("message"), 200, 0)
5598            .await
5599            .expect("legacy scan fixture loads");
5600        let expected_ids: Vec<_> = all_rows
5601            .iter()
5602            .filter(|note| legacy_outbox_pending(note, Some("email:"), now_micros))
5603            .map(|note| note.id)
5604            .collect();
5605        let actual_ids: Vec<_> = rt
5606            .list_undelivered_outbound_messages(&tok, Some("email:"), 200)
5607            .await
5608            .expect("filtered scan succeeds")
5609            .into_iter()
5610            .map(|note| note.id)
5611            .collect();
5612
5613        assert_eq!(
5614            expected_ids.len(),
5615            4,
5616            "fixture must exercise all exclusions"
5617        );
5618        assert_eq!(
5619            actual_ids, expected_ids,
5620            "filtered scan changed answer or order"
5621        );
5622    }
5623
5624    #[tokio::test]
5625    async fn list_undelivered_outbound_messages_skips_only_future_retry_deadlines() {
5626        let rt = rt();
5627        rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
5628        let tok = NamespaceToken::local();
5629        let store = rt.notes(&tok).expect("note store");
5630
5631        let mut future = outbound_message_note();
5632        future.properties = Some(serde_json::json!({
5633            "direction": "outbound",
5634            "next_attempt_at": "2999-01-01T00:00:00Z",
5635        }));
5636        let future_id = future.id;
5637
5638        let mut overdue = outbound_message_note();
5639        overdue.properties = Some(serde_json::json!({
5640            "direction": "outbound",
5641            "next_attempt_at": "2000-01-01T00:00:00Z",
5642        }));
5643        let overdue_id = overdue.id;
5644
5645        let mut malformed = outbound_message_note();
5646        malformed.properties = Some(serde_json::json!({
5647            "direction": "outbound",
5648            "next_attempt_at": "not-a-timestamp",
5649        }));
5650        let malformed_id = malformed.id;
5651
5652        for note in [future, overdue, malformed] {
5653            store.upsert_note(note).await.expect("seed note");
5654        }
5655
5656        let ids: HashSet<_> = rt
5657            .list_undelivered_outbound_messages(&tok, None, 200)
5658            .await
5659            .expect("scan succeeds")
5660            .into_iter()
5661            .map(|note| note.id)
5662            .collect();
5663
5664        assert!(!ids.contains(&future_id), "future retry must stay parked");
5665        assert!(ids.contains(&overdue_id), "overdue retry must be eligible");
5666        assert!(
5667            ids.contains(&malformed_id),
5668            "malformed legacy retry state must fail open instead of stranding the note"
5669        );
5670    }
5671
5672    /// The channel prefix runs BEFORE the limit: a backlog of another
5673    /// channel's pending rows must not consume the scan budget and starve
5674    /// the requested channel (the pre-fix defect: filter-after-limit).
5675    #[tokio::test]
5676    async fn list_undelivered_outbound_messages_prefix_filters_before_limit() {
5677        let rt = rt();
5678        rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
5679        let tok = NamespaceToken::local();
5680        let store = rt.notes(&tok).expect("note store");
5681
5682        // Older email row behind three newer telegram rows.
5683        let mut email = outbound_message_note();
5684        email.created_at -= 100;
5685        email.properties =
5686            Some(serde_json::json!({"direction": "outbound", "to_actor": "email:a@b.c"}));
5687        let email_id = email.id;
5688        store.upsert_note(email).await.expect("seed email");
5689        for _ in 0..3 {
5690            let mut tg = outbound_message_note();
5691            tg.properties =
5692                Some(serde_json::json!({"direction": "outbound", "to_actor": "telegram:42"}));
5693            store.upsert_note(tg).await.expect("seed telegram");
5694        }
5695
5696        // With filter-after-limit this would return a telegram row (newest
5697        // first) and the email loop would see nothing deliverable.
5698        let hits = rt
5699            .list_undelivered_outbound_messages(&tok, Some("email:"), 1)
5700            .await
5701            .expect("scan succeeds");
5702        assert_eq!(
5703            hits.iter().map(|n| n.id).collect::<Vec<_>>(),
5704            vec![email_id],
5705            "prefix predicate applies before the limit"
5706        );
5707
5708        let telegram_hits = rt
5709            .list_undelivered_outbound_messages(&tok, Some("telegram:"), 200)
5710            .await
5711            .expect("scan succeeds");
5712        assert_eq!(telegram_hits.len(), 3, "telegram prefix sees its own rows");
5713    }
5714
5715    /// Terminal-outcome markers: `delivered` stamps the ADR-122 §1 property
5716    /// set (with `transport_message_id` only when given), `failed` stamps
5717    /// §2's permanent-failure set, and both refuse targets that are not live
5718    /// outbound message notes.
5719    #[tokio::test]
5720    async fn outbound_delivery_markers_stamp_adr122_properties_and_validate_target() {
5721        let rt = rt();
5722        rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
5723        let tok = NamespaceToken::local();
5724        let store = rt.notes(&tok).expect("note store");
5725
5726        let delivered_note = outbound_message_note();
5727        let delivered_id = delivered_note.id;
5728        let delivered_version = delivered_note.version;
5729        let failed_note = outbound_message_note();
5730        let failed_id = failed_note.id;
5731        let failed_version = failed_note.version;
5732        let mut inbound = Note::new("local", "message", "inbound row");
5733        inbound.properties = Some(serde_json::json!({"direction": "inbound"}));
5734        let inbound_id = inbound.id;
5735        let wrong_kind = Note::new("local", "observation", "not a message");
5736        let wrong_kind_id = wrong_kind.id;
5737        for note in [delivered_note, failed_note] {
5738            store.upsert_note(note).await.expect("seed note");
5739        }
5740        store.upsert_note(inbound).await.expect("seed inbound");
5741        store
5742            .upsert_note(wrong_kind)
5743            .await
5744            .expect("seed non-message");
5745
5746        let marked = rt
5747            .mark_outbound_message_delivered(
5748                &tok,
5749                delivered_id,
5750                "2026-08-28T00:00:00Z".to_string(),
5751                Some("<mid@example>".to_string()),
5752            )
5753            .await
5754            .expect("mark delivered succeeds");
5755        assert_eq!(marked.version, delivered_version + 1);
5756        assert_eq!(marked, store.get_note(delivered_id).await.unwrap().unwrap());
5757        let props = marked
5758            .properties
5759            .as_ref()
5760            .and_then(|v| v.as_object())
5761            .unwrap();
5762        assert_eq!(
5763            props.get("delivery").and_then(|v| v.as_str()),
5764            Some("delivered")
5765        );
5766        assert_eq!(
5767            props.get("delivered_at").and_then(|v| v.as_str()),
5768            Some("2026-08-28T00:00:00Z")
5769        );
5770        assert_eq!(
5771            props.get("transport_message_id").and_then(|v| v.as_str()),
5772            Some("<mid@example>")
5773        );
5774
5775        let failed = rt
5776            .mark_outbound_message_failed(
5777                &tok,
5778                failed_id,
5779                "2026-08-28T00:00:01Z".to_string(),
5780                "recipient not in allowlist".to_string(),
5781            )
5782            .await
5783            .expect("mark failed succeeds");
5784        assert_eq!(failed.version, failed_version + 1);
5785        assert_eq!(failed, store.get_note(failed_id).await.unwrap().unwrap());
5786        let props = failed
5787            .properties
5788            .as_ref()
5789            .and_then(|v| v.as_object())
5790            .unwrap();
5791        assert_eq!(
5792            props.get("delivery").and_then(|v| v.as_str()),
5793            Some("failed")
5794        );
5795        assert_eq!(
5796            props.get("failed_at").and_then(|v| v.as_str()),
5797            Some("2026-08-28T00:00:01Z")
5798        );
5799        assert_eq!(
5800            props.get("last_error").and_then(|v| v.as_str()),
5801            Some("recipient not in allowlist")
5802        );
5803
5804        // Both marked rows are now terminal: the scan must not return them.
5805        let pending = rt
5806            .list_undelivered_outbound_messages(&tok, None, 200)
5807            .await
5808            .expect("scan succeeds");
5809        assert!(
5810            pending.is_empty(),
5811            "terminal rows left in scan: {:?}",
5812            pending.iter().map(|n| n.id).collect::<Vec<_>>()
5813        );
5814
5815        // Validation arms: inbound message and non-message kind both refuse.
5816        for (id, label) in [(inbound_id, "inbound"), (wrong_kind_id, "non-message")] {
5817            let err = rt
5818                .mark_outbound_message_delivered(&tok, id, "t".to_string(), None)
5819                .await
5820                .expect_err(label);
5821            assert!(
5822                matches!(err, RuntimeError::InvalidInput(_)),
5823                "{label}: expected InvalidInput, got {err:?}"
5824            );
5825            let err = rt
5826                .mark_outbound_message_failed(&tok, id, "t".to_string(), "e".to_string())
5827                .await
5828                .expect_err(label);
5829            assert!(
5830                matches!(err, RuntimeError::InvalidInput(_)),
5831                "{label}: expected InvalidInput, got {err:?}"
5832            );
5833        }
5834    }
5835
5836    /// Regression: two concurrent outbox workers (two overlapping daemon
5837    /// processes during a restart, per ADR-122 §4/Consequences) can both
5838    /// load the same pending note before either writes. Without a terminal
5839    /// re-check, a worker that lost the race to record a failure still
5840    /// re-fetches a fresh snapshot right before writing -- which already
5841    /// carries the winner's "delivered" outcome -- and the compare-and-swap
5842    /// alone does not stop it from blindly overwriting that success with
5843    /// "failed" (or vice versa).
5844    #[tokio::test]
5845    async fn outbound_delivery_marker_refuses_to_overwrite_existing_terminal_outcome() {
5846        let rt = rt();
5847        rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
5848        let tok = NamespaceToken::local();
5849        let store = rt.notes(&tok).expect("note store");
5850
5851        let delivered_first = outbound_message_note();
5852        let delivered_first_id = delivered_first.id;
5853        let failed_first = outbound_message_note();
5854        let failed_first_id = failed_first.id;
5855        for note in [delivered_first, failed_first] {
5856            store.upsert_note(note).await.expect("seed note");
5857        }
5858
5859        rt.mark_outbound_message_delivered(
5860            &tok,
5861            delivered_first_id,
5862            "2026-08-28T00:00:00Z".to_string(),
5863            None,
5864        )
5865        .await
5866        .expect("first delivery marker wins the race");
5867
5868        let err = rt
5869            .mark_outbound_message_failed(
5870                &tok,
5871                delivered_first_id,
5872                "2026-08-28T00:00:01Z".to_string(),
5873                "duplicate send rejected".to_string(),
5874            )
5875            .await
5876            .expect_err("a losing failure marker must not clobber the recorded success");
5877        assert!(matches!(err, RuntimeError::InvalidInput(_)));
5878
5879        let note = store
5880            .get_note(delivered_first_id)
5881            .await
5882            .expect("get note")
5883            .expect("note exists");
5884        let props = note
5885            .properties
5886            .as_ref()
5887            .and_then(|v| v.as_object())
5888            .unwrap();
5889        assert_eq!(
5890            props.get("delivery").and_then(|v| v.as_str()),
5891            Some("delivered"),
5892            "recorded success must survive the losing worker's overwrite attempt"
5893        );
5894
5895        rt.mark_outbound_message_failed(
5896            &tok,
5897            failed_first_id,
5898            "2026-08-28T00:00:00Z".to_string(),
5899            "recipient rejected".to_string(),
5900        )
5901        .await
5902        .expect("first failure marker wins the race");
5903
5904        let err = rt
5905            .mark_outbound_message_delivered(
5906                &tok,
5907                failed_first_id,
5908                "2026-08-28T00:00:01Z".to_string(),
5909                None,
5910            )
5911            .await
5912            .expect_err("a late success marker must not clobber a recorded failure");
5913        assert!(matches!(err, RuntimeError::InvalidInput(_)));
5914    }
5915
5916    #[tokio::test]
5917    async fn outbound_transient_failure_increments_attempts_and_arms_bounded_backoff() {
5918        let rt = rt();
5919        rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
5920        let tok = NamespaceToken::local();
5921        let store = rt.notes(&tok).expect("note store");
5922
5923        let mut note = outbound_message_note();
5924        note.properties = Some(serde_json::json!({
5925            "direction": "outbound",
5926            "delivery_attempts": 3,
5927            "unrelated": "preserved",
5928        }));
5929        let id = note.id;
5930        let original_version = note.version;
5931        store.upsert_note(note).await.expect("seed note");
5932
5933        let attempted_at = chrono::DateTime::parse_from_rfc3339("2026-08-30T00:00:00Z")
5934            .unwrap()
5935            .with_timezone(&chrono::Utc);
5936        let marked = rt
5937            .mark_outbound_message_transient_failure(
5938                &tok,
5939                id,
5940                attempted_at,
5941                "mailbox temporarily unavailable".to_string(),
5942                std::time::Duration::from_secs(5),
5943                std::time::Duration::from_secs(20),
5944            )
5945            .await
5946            .expect("transient marker succeeds");
5947        assert_eq!(marked.version, original_version + 1);
5948        assert_eq!(marked, store.get_note(id).await.unwrap().unwrap());
5949        let props = marked.properties.unwrap();
5950
5951        assert_eq!(props["delivery_attempts"].as_u64(), Some(4));
5952        assert_eq!(
5953            props["next_attempt_at"].as_str(),
5954            Some("2026-08-30T00:00:20+00:00"),
5955            "5 * 2^(4-1) is capped at the configured 20-second ceiling"
5956        );
5957        assert_eq!(
5958            props["last_error"].as_str(),
5959            Some("mailbox temporarily unavailable")
5960        );
5961        assert_eq!(props["unrelated"].as_str(), Some("preserved"));
5962        assert!(
5963            props.get("delivery").is_none(),
5964            "a transient failure must remain pending"
5965        );
5966    }
5967
5968    #[tokio::test]
5969    async fn outbound_terminal_markers_clear_retry_schedule() {
5970        let rt = rt();
5971        rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
5972        let tok = NamespaceToken::local();
5973        let store = rt.notes(&tok).expect("note store");
5974
5975        let mut delivered = outbound_message_note();
5976        delivered.properties = Some(serde_json::json!({
5977            "direction": "outbound",
5978            "delivery_attempts": 2,
5979            "next_attempt_at": "2999-01-01T00:00:00Z",
5980            "last_error": "temporary",
5981        }));
5982        let delivered_id = delivered.id;
5983
5984        let mut failed = outbound_message_note();
5985        failed.properties = Some(serde_json::json!({
5986            "direction": "outbound",
5987            "delivery_attempts": 7,
5988            "next_attempt_at": "2999-01-01T00:00:00Z",
5989        }));
5990        let failed_id = failed.id;
5991
5992        for note in [delivered, failed] {
5993            store.upsert_note(note).await.expect("seed note");
5994        }
5995
5996        let delivered = rt
5997            .mark_outbound_message_delivered(
5998                &tok,
5999                delivered_id,
6000                "2026-08-30T00:00:00Z".to_string(),
6001                None,
6002            )
6003            .await
6004            .expect("delivery marker succeeds");
6005        let delivered_props = delivered.properties.unwrap();
6006        assert!(delivered_props.get("delivery_attempts").is_none());
6007        assert!(delivered_props.get("next_attempt_at").is_none());
6008        assert_eq!(delivered_props["last_error"].as_str(), Some("temporary"));
6009
6010        let failed = rt
6011            .mark_outbound_message_failed(
6012                &tok,
6013                failed_id,
6014                "2026-08-30T00:00:01Z".to_string(),
6015                "recipient rejected".to_string(),
6016            )
6017            .await
6018            .expect("permanent marker succeeds");
6019        let failed_props = failed.properties.unwrap();
6020        assert!(failed_props.get("delivery_attempts").is_none());
6021        assert!(failed_props.get("next_attempt_at").is_none());
6022        assert_eq!(failed_props["delivery"].as_str(), Some("failed"));
6023    }
6024
6025    #[tokio::test]
6026    async fn claim_outbound_message_external_id_sets_value_and_survives_readback() {
6027        let rt = rt();
6028        rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
6029        let tok = NamespaceToken::local();
6030        let note = outbound_message_note();
6031        let note_id = note.id;
6032        rt.notes(&tok)
6033            .expect("note store")
6034            .upsert_note(note)
6035            .await
6036            .expect("seed note");
6037
6038        let claimed = rt
6039            .claim_outbound_message_external_id(&tok, note_id, "<abc@example.com>".to_string())
6040            .await
6041            .expect("claim succeeds on a fresh outbound message note");
6042        assert_eq!(
6043            claimed
6044                .properties
6045                .as_ref()
6046                .and_then(|p| p.get("external_id"))
6047                .and_then(|v| v.as_str()),
6048            Some("<abc@example.com>")
6049        );
6050
6051        // Reads the persisted row back independently of the claim call's own
6052        // return value. This is the check that fails if the fix is reverted
6053        // to routing the claim through `dispatch("update", ...)`: that path is
6054        // refused by the owner-established-property gate exercised in
6055        // `generic_update_still_refuses_external_id_on_message_note` below, so
6056        // external_id would never actually persist and this read would come
6057        // back `None`.
6058        let reread = rt
6059            .notes(&tok)
6060            .expect("note store")
6061            .get_note(note_id)
6062            .await
6063            .expect("read note")
6064            .expect("note still exists");
6065        assert_eq!(
6066            reread
6067                .properties
6068                .as_ref()
6069                .and_then(|p| p.get("external_id"))
6070                .and_then(|v| v.as_str()),
6071            Some("<abc@example.com>")
6072        );
6073    }
6074
6075    #[tokio::test]
6076    async fn generic_update_still_refuses_external_id_on_message_note() {
6077        let rt = rt();
6078        rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
6079        let tok = NamespaceToken::local();
6080        let note = outbound_message_note();
6081        let note_id = note.id;
6082        rt.notes(&tok)
6083            .expect("note store")
6084            .upsert_note(note)
6085            .await
6086            .expect("seed note");
6087
6088        let err = rt
6089            .update_note(
6090                &tok,
6091                note_id,
6092                NotePatch {
6093                    properties: Some(serde_json::json!({"external_id": "<forged@example.com>"})),
6094                    ..Default::default()
6095                },
6096            )
6097            .await
6098            .expect_err("caller-facing update must keep refusing external_id on a message note");
6099        assert!(matches!(err, RuntimeError::InvalidInput(_)), "error: {err}");
6100        assert!(err.to_string().contains("is not patchable"), "error: {err}");
6101
6102        // The owner path is unaffected by the caller-side refusal above.
6103        let claimed = rt
6104            .claim_outbound_message_external_id(&tok, note_id, "<claimed@example.com>".to_string())
6105            .await
6106            .expect("owner-bookkeeping path still claims after a refused caller patch");
6107        assert_eq!(
6108            claimed
6109                .properties
6110                .as_ref()
6111                .and_then(|p| p.get("external_id"))
6112                .and_then(|v| v.as_str()),
6113            Some("<claimed@example.com>")
6114        );
6115    }
6116
6117    #[tokio::test]
6118    async fn claim_failure_snapshot_cannot_park_a_concurrent_successful_claim() {
6119        let rt = rt();
6120        rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
6121        let token = NamespaceToken::local();
6122        let mut before = outbound_message_note();
6123        // The owner must preserve existing trusted transport provenance.
6124        before
6125            .properties
6126            .as_mut()
6127            .unwrap()
6128            .as_object_mut()
6129            .unwrap()
6130            .insert("channel_kind".into(), serde_json::json!("email"));
6131        rt.raw_notes(&token)
6132            .unwrap()
6133            .upsert_note(before.clone())
6134            .await
6135            .unwrap();
6136        let claimed = rt
6137            .claim_outbound_message_external_id(&token, before.id, "<winner@example.com>".into())
6138            .await
6139            .unwrap();
6140        assert!(claimed.updated_at > before.updated_at);
6141        assert_eq!(
6142            claimed.properties.as_ref().unwrap()["channel_kind"],
6143            "email"
6144        );
6145
6146        rt.mark_outbound_message_claim_failed_from_snapshot(
6147            &token,
6148            before,
6149            "2026-09-22T12:00:00Z".into(),
6150            "claim refused".into(),
6151        )
6152        .await
6153        .expect_err("the stale pre-claim snapshot cannot overwrite the winner");
6154        let returned = rt
6155            .mark_outbound_message_claim_failed(
6156                &token,
6157                claimed.id,
6158                "2026-09-22T12:00:00Z".into(),
6159                "already claimed".into(),
6160            )
6161            .await
6162            .unwrap();
6163        let stored = rt
6164            .notes(&token)
6165            .unwrap()
6166            .get_note(claimed.id)
6167            .await
6168            .unwrap()
6169            .unwrap();
6170        assert_eq!(
6171            serde_json::to_value(returned).unwrap(),
6172            serde_json::to_value(&claimed).unwrap()
6173        );
6174        assert_eq!(
6175            serde_json::to_value(stored).unwrap(),
6176            serde_json::to_value(claimed).unwrap()
6177        );
6178    }
6179
6180    #[tokio::test]
6181    async fn claim_failure_parks_only_unclaimed_pending_messages() {
6182        let rt = rt();
6183        rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
6184        let token = NamespaceToken::local();
6185        for outcome in [None, Some("delivered"), Some("failed")] {
6186            let mut note = outbound_message_note();
6187            let props = note.properties.as_mut().unwrap().as_object_mut().unwrap();
6188            props.insert("channel_kind".into(), serde_json::json!("email"));
6189            if let Some(outcome) = outcome {
6190                props.insert("delivery".into(), serde_json::json!(outcome));
6191            }
6192            rt.raw_notes(&token)
6193                .unwrap()
6194                .upsert_note(note.clone())
6195                .await
6196                .unwrap();
6197            let result = rt
6198                .mark_outbound_message_claim_failed(
6199                    &token,
6200                    note.id,
6201                    "2026-09-22T12:00:00Z".into(),
6202                    "claim refused".into(),
6203                )
6204                .await
6205                .unwrap();
6206            if outcome.is_some() {
6207                assert_eq!(
6208                    serde_json::to_value(&result).unwrap(),
6209                    serde_json::to_value(note).unwrap()
6210                );
6211            } else {
6212                assert_eq!(result.properties.as_ref().unwrap()["delivery"], "failed");
6213                assert_eq!(result.properties.as_ref().unwrap()["channel_kind"], "email");
6214                assert!(result.updated_at > note.updated_at);
6215                assert_eq!(result.version, note.version + 1, "one parking write");
6216            }
6217            assert_eq!(
6218                serde_json::to_value(
6219                    rt.notes(&token)
6220                        .unwrap()
6221                        .get_note(result.id)
6222                        .await
6223                        .unwrap()
6224                        .unwrap()
6225                )
6226                .unwrap(),
6227                serde_json::to_value(result).unwrap()
6228            );
6229        }
6230    }
6231
6232    #[tokio::test]
6233    async fn claim_refuses_non_message_note() {
6234        let rt = rt();
6235        rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
6236        let tok = NamespaceToken::local();
6237        let mut note = Note::new("local", "observation", "not a message");
6238        note.properties = Some(serde_json::json!({"direction": "outbound"}));
6239        let note_id = note.id;
6240        rt.notes(&tok)
6241            .expect("note store")
6242            .upsert_note(note)
6243            .await
6244            .expect("seed note");
6245
6246        let err = rt
6247            .claim_outbound_message_external_id(&tok, note_id, "<x@example.com>".to_string())
6248            .await
6249            .expect_err("a non-message note must never accept the claim");
6250        assert!(matches!(err, RuntimeError::InvalidInput(_)), "error: {err}");
6251    }
6252
6253    #[tokio::test]
6254    async fn claim_refuses_inbound_message() {
6255        let rt = rt();
6256        rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
6257        let tok = NamespaceToken::local();
6258        let mut note = Note::new("local", "message", "inbound content");
6259        note.properties = Some(serde_json::json!({"direction": "inbound"}));
6260        let note_id = note.id;
6261        rt.notes(&tok)
6262            .expect("note store")
6263            .upsert_note(note)
6264            .await
6265            .expect("seed note");
6266
6267        let err = rt
6268            .claim_outbound_message_external_id(&tok, note_id, "<x@example.com>".to_string())
6269            .await
6270            .expect_err("an inbound message note must never accept the claim");
6271        assert!(matches!(err, RuntimeError::InvalidInput(_)), "error: {err}");
6272    }
6273
6274    #[tokio::test]
6275    async fn claim_refuses_when_external_id_already_set() {
6276        let rt = rt();
6277        rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
6278        let tok = NamespaceToken::local();
6279        let mut note = outbound_message_note();
6280        note.properties = Some(
6281            serde_json::json!({"direction": "outbound", "external_id": "<already@example.com>"}),
6282        );
6283        let note_id = note.id;
6284        rt.notes(&tok)
6285            .expect("note store")
6286            .upsert_note(note)
6287            .await
6288            .expect("seed note");
6289
6290        let err = rt
6291            .claim_outbound_message_external_id(&tok, note_id, "<new@example.com>".to_string())
6292            .await
6293            .expect_err("a note that already carries external_id must refuse re-claim");
6294        assert!(matches!(err, RuntimeError::InvalidInput(_)), "error: {err}");
6295    }
6296
6297    #[tokio::test]
6298    async fn generic_update_can_still_patch_delivered_at_on_message_note() {
6299        let rt = rt();
6300        rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
6301        let tok = NamespaceToken::local();
6302        let note = outbound_message_note();
6303        let note_id = note.id;
6304        rt.notes(&tok)
6305            .expect("note store")
6306            .upsert_note(note)
6307            .await
6308            .expect("seed note");
6309
6310        let updated = rt
6311            .update_note(
6312                &tok,
6313                note_id,
6314                NotePatch {
6315                    properties: Some(serde_json::json!({"delivered_at": "2026-08-09T00:00:00Z"})),
6316                    ..Default::default()
6317                },
6318            )
6319            .await
6320            .expect("delivered_at is not owner-established and must remain patchable");
6321        assert_eq!(
6322            updated
6323                .properties
6324                .as_ref()
6325                .and_then(|p| p.get("delivered_at"))
6326                .and_then(|v| v.as_str()),
6327            Some("2026-08-09T00:00:00Z")
6328        );
6329    }
6330
6331    fn secret_shaped_reason() -> String {
6332        const ALPHANUMERIC: &[u8] =
6333            b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
6334        let candidate: String = (0..48)
6335            .map(|index| char::from(ALPHANUMERIC[(index * 17 + 11) % ALPHANUMERIC.len()]))
6336            .collect();
6337        format!("secret value: {candidate}")
6338    }
6339
6340    #[test]
6341    fn note_embedding_text_ref_borrows_stored_content() {
6342        let note = Note::new("embedding-borrow", "observation", "borrow this content");
6343        let text = note_embedding_text_ref(&note);
6344
6345        assert_eq!(text, note.content.as_str());
6346        assert!(
6347            std::ptr::eq(text, note.content.as_str()),
6348            "internal canonical note text must borrow instead of cloning"
6349        );
6350        let owned: String = note_embedding_text(&note);
6351        assert_eq!(owned.as_str(), note.content.as_str());
6352    }
6353
6354    #[tokio::test]
6355    async fn generic_note_update_errors_when_revision_is_already_i64_max() {
6356        let rt = rt();
6357        let tok = NamespaceToken::local();
6358        let mut note = Note::new("local", "observation", "saturated revision");
6359        note.updated_at = i64::MAX;
6360        let note_id = note.id;
6361        rt.notes(&tok)
6362            .expect("note store")
6363            .upsert_note(note.clone())
6364            .await
6365            .expect("seed saturated note");
6366
6367        let error = rt
6368            .update_note(
6369                &tok,
6370                note_id,
6371                NotePatch::new(None, Some("must not land".to_string()), None, None, None),
6372            )
6373            .await
6374            .expect_err("i64::MAX cannot yield a strictly newer CAS revision");
6375        assert!(
6376            matches!(&error, RuntimeError::Internal(_)),
6377            "revision exhaustion is an internal persisted-state error: {error}"
6378        );
6379        assert!(error.to_string().contains("i64::MAX"), "error: {error}");
6380
6381        let persisted = rt
6382            .notes(&tok)
6383            .expect("note store")
6384            .get_note(note_id)
6385            .await
6386            .expect("read note")
6387            .expect("note remains live");
6388        assert_eq!(persisted, note, "failed revision advance must not mutate");
6389    }
6390
6391    async fn restore_edge_preimage(
6392        rt: &KhiveRuntime,
6393        token: &NamespaceToken,
6394        preimage: &MergeEdgePreimage,
6395    ) {
6396        let edge = khive_storage::types::Edge {
6397            id: preimage.id.into(),
6398            namespace: preimage.namespace.clone(),
6399            source_id: preimage.source_id,
6400            target_id: preimage.target_id,
6401            relation: preimage.relation.parse().expect("stored relation"),
6402            weight: preimage.weight,
6403            created_at: chrono::DateTime::from_timestamp_micros(preimage.created_at)
6404                .expect("stored created_at"),
6405            updated_at: chrono::DateTime::from_timestamp_micros(preimage.updated_at)
6406                .expect("stored updated_at"),
6407            deleted_at: preimage.deleted_at.map(|value| {
6408                chrono::DateTime::from_timestamp_micros(value).expect("stored deleted_at")
6409            }),
6410            metadata: preimage.metadata.clone(),
6411            target_backend: preimage.target_backend.clone(),
6412        };
6413        rt.graph(token)
6414            .expect("graph store")
6415            .upsert_edge(edge)
6416            .await
6417            .expect("restore edge preimage");
6418    }
6419
6420    fn assert_edge_matches_preimage(
6421        edge: &khive_storage::types::Edge,
6422        preimage: &MergeEdgePreimage,
6423    ) {
6424        assert_eq!(Uuid::from(edge.id), preimage.id);
6425        assert_eq!(edge.namespace, preimage.namespace);
6426        assert_eq!(edge.source_id, preimage.source_id);
6427        assert_eq!(edge.target_id, preimage.target_id);
6428        assert_eq!(edge.relation.to_string(), preimage.relation);
6429        assert_eq!(edge.weight, preimage.weight);
6430        assert_eq!(edge.created_at.timestamp_micros(), preimage.created_at);
6431        assert_eq!(edge.updated_at.timestamp_micros(), preimage.updated_at);
6432        assert_eq!(
6433            edge.deleted_at.map(|value| value.timestamp_micros()),
6434            preimage.deleted_at
6435        );
6436        assert_eq!(edge.metadata, preimage.metadata);
6437        assert_eq!(edge.target_backend, preimage.target_backend);
6438    }
6439
6440    // Helper: search FTS5 for `query` in a runtime namespace.
6441    async fn fts_hit(rt: &KhiveRuntime, token: &NamespaceToken, query: &str) -> Vec<Uuid> {
6442        let ns = token.namespace().as_str().to_string();
6443        rt.text(token)
6444            .unwrap()
6445            .search(TextSearchRequest {
6446                query: query.to_string(),
6447                mode: TextQueryMode::Plain,
6448                filter: Some(TextFilter {
6449                    namespaces: vec![ns],
6450                    ..Default::default()
6451                }),
6452                top_k: 50,
6453                snippet_chars: 100,
6454            })
6455            .await
6456            .unwrap()
6457            .into_iter()
6458            .map(|h| h.subject_id)
6459            .collect()
6460    }
6461
6462    #[tokio::test]
6463    async fn update_entity_patch_changes_only_specified_fields() {
6464        let rt = rt();
6465        let tok = NamespaceToken::local();
6466        let entity = rt
6467            .create_entity(
6468                &tok,
6469                "concept",
6470                None,
6471                "OriginalName",
6472                Some("orig desc"),
6473                Some(serde_json::json!({"k":"v"})),
6474                vec![],
6475            )
6476            .await
6477            .unwrap();
6478
6479        let updated = rt
6480            .update_entity(
6481                &tok,
6482                entity.id,
6483                EntityPatch {
6484                    description: Some(Some("new desc".to_string())),
6485                    ..Default::default()
6486                },
6487            )
6488            .await
6489            .unwrap();
6490
6491        assert_eq!(updated.name, "OriginalName");
6492        assert_eq!(updated.description.as_deref(), Some("new desc"));
6493        assert_eq!(updated.properties, Some(serde_json::json!({"k":"v"})));
6494    }
6495
6496    #[tokio::test]
6497    async fn update_entity_if_unchanged_removes_properties_after_merge() {
6498        let rt = rt();
6499        let tok = NamespaceToken::local();
6500        let entity = rt
6501            .create_entity(
6502                &tok,
6503                "concept",
6504                None,
6505                "LegacyEcho",
6506                Some("keep description"),
6507                Some(serde_json::json!({
6508                    "type": " Concept ",
6509                    "nested": {"items": [1, {"value": null}], "keep": true},
6510                    "label": "verbatim"
6511                })),
6512                vec!["keep-tag".to_string()],
6513            )
6514            .await
6515            .unwrap();
6516
6517        let updated = rt
6518            .update_entity_if_unchanged(
6519                &tok,
6520                &entity,
6521                EntityPatch {
6522                    properties: Some(serde_json::json!({"type": "also remove", "added": 7})),
6523                    ..Default::default()
6524                },
6525                &["type", "absent", "type"],
6526            )
6527            .await
6528            .expect("remove only the requested key after merging");
6529
6530        let mut expected = entity.clone();
6531        expected.properties = Some(serde_json::json!({
6532            "nested": {"items": [1, {"value": null}], "keep": true},
6533            "label": "verbatim",
6534            "added": 7
6535        }));
6536        assert!(updated.updated_at > entity.updated_at);
6537        expected.updated_at = updated.updated_at;
6538        expected.version = entity.version + 1;
6539        assert_eq!(serde_json::json!(updated), serde_json::json!(expected));
6540        assert_eq!(
6541            serde_json::json!(rt.get_entity(&tok, entity.id).await.unwrap()),
6542            serde_json::json!(expected)
6543        );
6544        let events = entity_update_events(&rt, &tok).await;
6545        assert_eq!(events.len(), 1);
6546        assert_eq!(
6547            events[0].payload["changed_fields"],
6548            serde_json::json!(["properties"])
6549        );
6550    }
6551
6552    #[tokio::test]
6553    async fn update_entity_if_unchanged_missing_removals_are_no_op() {
6554        let rt = rt();
6555        let tok = NamespaceToken::local();
6556        for properties in [
6557            None,
6558            Some(serde_json::json!({"keep": [true, null]})),
6559            Some(serde_json::json!(["not an object"])),
6560        ] {
6561            let entity = rt
6562                .create_entity(&tok, "concept", None, "NoRemoval", None, properties, vec![])
6563                .await
6564                .unwrap();
6565            let unchanged = rt
6566                .update_entity_if_unchanged(&tok, &entity, EntityPatch::default(), &["absent"])
6567                .await
6568                .unwrap();
6569            assert_eq!(serde_json::json!(unchanged), serde_json::json!(entity));
6570            assert_eq!(
6571                serde_json::json!(rt.get_entity(&tok, entity.id).await.unwrap()),
6572                serde_json::json!(entity)
6573            );
6574        }
6575        assert!(entity_update_events(&rt, &tok).await.is_empty());
6576    }
6577
6578    #[tokio::test]
6579    async fn update_entity_if_unchanged_refuses_stale_full_snapshot() {
6580        let rt = rt();
6581        let tok = NamespaceToken::local();
6582        let entity = rt
6583            .create_entity(
6584                &tok,
6585                "concept",
6586                None,
6587                "StaleBackfill",
6588                None,
6589                Some(serde_json::json!({"type": "concept", "keep": true})),
6590                vec![],
6591            )
6592            .await
6593            .unwrap();
6594        for field in ["properties", "entity_type", "deleted_at", "updated_at"] {
6595            let mut stale = entity.clone();
6596            match field {
6597                "properties" => stale.properties = Some(serde_json::json!({"type": "algorithm"})),
6598                "entity_type" => stale.entity_type = Some("algorithm".to_string()),
6599                "deleted_at" => stale.deleted_at = Some(entity.updated_at),
6600                "updated_at" => stale.updated_at -= 1,
6601                _ => unreachable!(),
6602            }
6603            let error = rt
6604                .update_entity_if_unchanged(
6605                    &tok,
6606                    &stale,
6607                    EntityPatch {
6608                        entity_type: Some(Some("algorithm".to_string())),
6609                        ..Default::default()
6610                    },
6611                    &["type"],
6612                )
6613                .await
6614                .expect_err("every stale snapshot field must refuse before edits");
6615            assert!(
6616                matches!(error, RuntimeError::Khive(ref error) if error.kind() == khive_types::ErrorKind::Conflict),
6617                "{field}: {error}"
6618            );
6619            assert_eq!(
6620                serde_json::json!(rt.get_entity(&tok, entity.id).await.unwrap()),
6621                serde_json::json!(entity),
6622                "{field}"
6623            );
6624        }
6625
6626        let store = rt.entities(&tok).unwrap();
6627        store
6628            .delete_entity(entity.id, khive_storage::types::DeleteMode::Soft)
6629            .await
6630            .unwrap();
6631        let tombstone = store
6632            .get_entity_including_deleted(entity.id)
6633            .await
6634            .unwrap()
6635            .unwrap();
6636        let error = rt
6637            .update_entity_if_unchanged(&tok, &entity, EntityPatch::default(), &["type"])
6638            .await
6639            .expect_err("a deleted candidate must not be resurrected");
6640        assert!(
6641            matches!(error, RuntimeError::Khive(ref error) if error.kind() == khive_types::ErrorKind::Conflict),
6642            "{error}"
6643        );
6644        assert_eq!(
6645            serde_json::json!(store
6646                .get_entity_including_deleted(entity.id)
6647                .await
6648                .unwrap()
6649                .unwrap()),
6650            serde_json::json!(tombstone)
6651        );
6652        assert!(entity_update_events(&rt, &tok).await.is_empty());
6653    }
6654
6655    #[tokio::test]
6656    async fn update_entity_if_unchanged_refuses_concurrent_writer_after_snapshot_check() {
6657        let rt = rt();
6658        let tok = NamespaceToken::local();
6659        let entity = rt
6660            .create_entity(
6661                &tok,
6662                "concept",
6663                None,
6664                "RacingBackfill",
6665                None,
6666                Some(serde_json::json!({"type": "concept", "keep": true})),
6667                vec![],
6668            )
6669            .await
6670            .unwrap();
6671        let barrier = Arc::new(tokio::sync::Barrier::new(2));
6672        let (guarded, normal) = tokio::join!(
6673            race_seam::AFTER_READ_BARRIER.scope(
6674                Arc::clone(&barrier),
6675                rt.update_entity_if_unchanged(&tok, &entity, EntityPatch::default(), &["type"]),
6676            ),
6677            race_seam::AFTER_READ_BARRIER.scope(
6678                barrier,
6679                rt.update_entity(
6680                    &tok,
6681                    entity.id,
6682                    EntityPatch {
6683                        properties: Some(serde_json::json!({"normal_writer": true})),
6684                        ..Default::default()
6685                    },
6686                ),
6687            ),
6688        );
6689        assert_eq!(
6690            usize::from(guarded.is_ok()) + usize::from(normal.is_ok()),
6691            1
6692        );
6693        let (winner, refused) = match (guarded, normal) {
6694            (Ok(winner), Err(refused)) | (Err(refused), Ok(winner)) => (winner, refused),
6695            results => panic!("exactly one writer must win: {results:?}"),
6696        };
6697        assert!(
6698            matches!(refused, RuntimeError::Khive(ref error) if error.kind() == khive_types::ErrorKind::Conflict),
6699            "{refused}"
6700        );
6701        assert_eq!(
6702            serde_json::json!(rt.get_entity(&tok, entity.id).await.unwrap()),
6703            serde_json::json!(winner)
6704        );
6705        assert_eq!(entity_update_events(&rt, &tok).await.len(), 1);
6706    }
6707
6708    #[tokio::test]
6709    async fn update_entity_if_unchanged_normalizes_type_with_installed_validator() {
6710        let rt = rt();
6711        let composed = khive_types::EntityTypeRegistry::with_extra([khive_types::EntityTypeDef {
6712            kind: khive_types::EntityKind::Document,
6713            type_name: "backfill_test_report",
6714            aliases: &["field_report"],
6715        }]);
6716        rt.install_entity_type_validator(Arc::new(move |kind, raw| {
6717            let kind = kind
6718                .parse::<khive_types::EntityKind>()
6719                .map_err(|error| RuntimeError::InvalidInput(error.to_string()))?;
6720            composed
6721                .resolve(kind, raw)
6722                .map(|resolved| resolved.entity_type)
6723                .map_err(RuntimeError::from)
6724        }));
6725        let tok = NamespaceToken::local();
6726        let entity = rt
6727            .create_entity(
6728                &tok,
6729                "document",
6730                None,
6731                "LegacySubtype",
6732                None,
6733                Some(serde_json::json!({"type": " Field-Report ", "keep": [1, 2]})),
6734                vec![],
6735            )
6736            .await
6737            .unwrap();
6738        let updated = rt
6739            .update_entity_if_unchanged(
6740                &tok,
6741                &entity,
6742                EntityPatch {
6743                    entity_type: Some(Some(" Field-Report ".to_string())),
6744                    ..Default::default()
6745                },
6746                &[],
6747            )
6748            .await
6749            .expect("normal write validation resolves pack-supplied aliases");
6750        assert_eq!(updated.entity_type.as_deref(), Some("backfill_test_report"));
6751        assert_eq!(updated.properties, entity.properties);
6752        assert_eq!(
6753            rt.get_entity(&tok, entity.id).await.unwrap().entity_type,
6754            updated.entity_type
6755        );
6756        let error = rt
6757            .update_entity_if_unchanged(
6758                &tok,
6759                &updated,
6760                EntityPatch {
6761                    entity_type: Some(Some("not_registered".to_string())),
6762                    ..Default::default()
6763                },
6764                &["type"],
6765            )
6766            .await
6767            .expect_err("invalid subtype refuses the entire patch and removal");
6768        assert!(matches!(error, RuntimeError::InvalidInput(_)), "{error}");
6769        assert_eq!(
6770            serde_json::json!(rt.get_entity(&tok, entity.id).await.unwrap()),
6771            serde_json::json!(updated)
6772        );
6773        let events = entity_update_events(&rt, &tok).await;
6774        assert_eq!(events.len(), 1);
6775        assert_eq!(
6776            events[0].payload["changed_fields"],
6777            serde_json::json!(["entity_type"])
6778        );
6779    }
6780
6781    #[tokio::test]
6782    async fn update_entity_if_unchanged_refuses_reserved_property_removal() {
6783        let rt = rt();
6784        let tok = NamespaceToken::local();
6785        let entity = rt
6786            .create_entity(&tok, "concept", None, "ReservedRemoval", None, None, vec![])
6787            .await
6788            .unwrap();
6789        let error = rt
6790            .update_entity_if_unchanged(
6791                &tok,
6792                &entity,
6793                EntityPatch::default(),
6794                &[crate::secret_gate::RESERVED_SECRET_GATE_KEY],
6795            )
6796            .await
6797            .expect_err("removal must share the reserved-property write validator");
6798        assert!(matches!(error, RuntimeError::InvalidInput(_)), "{error}");
6799        assert_eq!(
6800            serde_json::json!(rt.get_entity(&tok, entity.id).await.unwrap()),
6801            serde_json::json!(entity)
6802        );
6803        assert!(entity_update_events(&rt, &tok).await.is_empty());
6804    }
6805
6806    #[tokio::test]
6807    async fn update_entity_type_patch_validates_preserves_fields_and_requires_reindex() {
6808        let rt = rt();
6809        rt.install_entity_type_validator(std::sync::Arc::new(|kind, entity_type| {
6810            let Some(raw) = entity_type else {
6811                return Ok(None);
6812            };
6813            let normalized = raw.trim().to_ascii_lowercase();
6814            if kind == "concept" && normalized == "algorithm" {
6815                Ok(Some(normalized))
6816            } else {
6817                Err(RuntimeError::InvalidInput(format!(
6818                    "unknown entity_type {raw:?} for {kind:?}; valid: algorithm"
6819                )))
6820            }
6821        }));
6822        let tok = NamespaceToken::local();
6823        let entity = rt
6824            .create_entity(
6825                &tok,
6826                "concept",
6827                None,
6828                "HistoricalAlgorithm",
6829                Some("keep description"),
6830                Some(serde_json::json!({"type": "algorithm", "keep": true})),
6831                vec!["keep-tag".to_string()],
6832            )
6833            .await
6834            .unwrap();
6835
6836        let (
6837            prepared,
6838            reindex_required,
6839            changed_fields,
6840            _expected_updated_at,
6841            _expected_deleted_at,
6842        ) = rt
6843            .prepare_update_entity(
6844                &tok,
6845                entity.id,
6846                EntityPatch {
6847                    entity_type: Some(Some(" Algorithm ".to_string())),
6848                    ..Default::default()
6849                },
6850            )
6851            .await
6852            .expect("registered entity_type must validate");
6853
6854        assert_eq!(prepared.entity_type.as_deref(), Some("algorithm"));
6855        assert_eq!(prepared.name, "HistoricalAlgorithm");
6856        assert_eq!(prepared.description.as_deref(), Some("keep description"));
6857        assert_eq!(
6858            prepared.properties,
6859            Some(serde_json::json!({"type": "algorithm", "keep": true}))
6860        );
6861        assert_eq!(prepared.tags, vec!["keep-tag"]);
6862        assert!(
6863            reindex_required,
6864            "changing entity_type must request the normal entity reindex path"
6865        );
6866        assert_eq!(changed_fields, vec!["entity_type"]);
6867
6868        let err = rt
6869            .prepare_update_entity(
6870                &tok,
6871                entity.id,
6872                EntityPatch {
6873                    entity_type: Some(Some("not_registered".to_string())),
6874                    ..Default::default()
6875                },
6876            )
6877            .await
6878            .expect_err("unregistered entity_type must be rejected");
6879        assert!(matches!(err, RuntimeError::InvalidInput(_)), "error: {err}");
6880    }
6881
6882    /// Regression for the entity lost-update race (khive #1753): two writers
6883    /// read the same entity revision, then commit successive patches to
6884    /// independent properties fields. Before the guarded
6885    /// `replace_entity_if_unchanged` primitive, `update_entity_with_embedding_report`
6886    /// wrote an unconditional `entity_upsert_statement`, so both patches
6887    /// "succeeded" and the second silently discarded the first's field
6888    /// (`a=1` was overwritten back to `a=0` when B's stale full-row replace
6889    /// landed). This test forces the interleaving with a `Barrier` — both
6890    /// readers are released together, so both `prepare_update_entity` calls
6891    /// observe the SAME pre-write revision (asserted below) — then commits
6892    /// deterministically in a fixed A-then-B order. It reddens if the guard
6893    /// is dropped from `entity_replace_if_unchanged_statement` ENTIRELY: with
6894    /// an unconditional UPDATE (or `entity_upsert_statement`), B's write would
6895    /// also return `true` and `b` would be lost from the final properties.
6896    ///
6897    /// It does NOT redden when only `?8 > updated_at` is removed: with
6898    /// `updated_at = ?13` intact, B is refused by the revision guard whatever
6899    /// the clock did, so this fixture cannot see that conjunct disappear.
6900    ///
6901    /// It cannot ATTRIBUTE a failure to `updated_at = ?13` either, but for a
6902    /// different reason, and the difference matters. Both racers take their
6903    /// replacement revision from `prepare_update_entity`'s
6904    /// `max(now_micros, expected + 1)` above; this test pins the two EXPECTED
6905    /// revisions equal, never the two REPLACEMENT revisions. So with
6906    /// `updated_at = ?13` removed, whether B is still refused depends on
6907    /// whether B's wall-clock read happened to exceed A's committed revision.
6908    /// That is a race, not a property of the fixture, and no single run of it
6909    /// establishes either answer.
6910    ///
6911    /// Attribution therefore comes from fixtures that force the question:
6912    /// `entity_cas_refuses_a_replacement_revision_that_does_not_advance` for
6913    /// the strict-advance conjunct, and
6914    /// `production_update_entity_refuses_concurrent_stale_writer` for the
6915    /// production wiring. Making this fixture attribute as well would mean
6916    /// pinning both replacement revisions to a common `expected + 1`; it is
6917    /// deliberately left as a whole-guard test instead.
6918    ///
6919    /// SCOPE: this exercises the STORE PRIMITIVE directly and never invokes
6920    /// `update_entity`, so it stays green if the production caller is reverted
6921    /// to an unconditional write. The wiring is covered separately by
6922    /// `production_update_entity_refuses_concurrent_stale_writer`; both are
6923    /// required, neither substitutes for the other.
6924    #[tokio::test]
6925    async fn concurrent_entity_property_patches_from_one_revision_only_one_survives() {
6926        let rt = Arc::new(rt());
6927        let tok = NamespaceToken::local();
6928        let entity = rt
6929            .create_entity(
6930                &tok,
6931                "concept",
6932                None,
6933                "RaceTarget",
6934                None,
6935                Some(serde_json::json!({"a": 0, "b": 0})),
6936                vec![],
6937            )
6938            .await
6939            .expect("seed entity");
6940        let id = entity.id;
6941
6942        let barrier = Arc::new(tokio::sync::Barrier::new(2));
6943
6944        let reader_a = {
6945            let rt = Arc::clone(&rt);
6946            let tok = tok.clone();
6947            let barrier = Arc::clone(&barrier);
6948            tokio::spawn(async move {
6949                barrier.wait().await;
6950                rt.prepare_update_entity(
6951                    &tok,
6952                    id,
6953                    EntityPatch {
6954                        properties: Some(serde_json::json!({"a": 1})),
6955                        ..Default::default()
6956                    },
6957                )
6958                .await
6959            })
6960        };
6961        let reader_b = {
6962            let rt = Arc::clone(&rt);
6963            let tok = tok.clone();
6964            let barrier = Arc::clone(&barrier);
6965            tokio::spawn(async move {
6966                barrier.wait().await;
6967                rt.prepare_update_entity(
6968                    &tok,
6969                    id,
6970                    EntityPatch {
6971                        properties: Some(serde_json::json!({"b": 1})),
6972                        ..Default::default()
6973                    },
6974                )
6975                .await
6976            })
6977        };
6978
6979        let (entity_a, _, _, expected_updated_at_a, expected_deleted_at_a) =
6980            reader_a.await.unwrap().expect("reader A prepares");
6981        let (entity_b, _, _, expected_updated_at_b, expected_deleted_at_b) =
6982            reader_b.await.unwrap().expect("reader B prepares");
6983        assert_eq!(
6984            expected_updated_at_a, expected_updated_at_b,
6985            "both readers must observe the same pre-write revision for this to be a real race"
6986        );
6987
6988        let store = rt.entities(&tok).expect("entity store");
6989        assert!(
6990            store
6991                .replace_entity_if_unchanged(entity_a, expected_updated_at_a, expected_deleted_at_a)
6992                .await
6993                .expect("writer A CAS query"),
6994            "the first committer from a shared revision must win"
6995        );
6996        assert!(
6997            !store
6998                .replace_entity_if_unchanged(entity_b, expected_updated_at_b, expected_deleted_at_b)
6999                .await
7000                .expect("writer B CAS query"),
7001            "the second committer from the SAME stale revision must be refused, not merged"
7002        );
7003
7004        let final_entity = rt.get_entity(&tok, id).await.expect("read final entity");
7005        assert_eq!(
7006            final_entity.properties,
7007            Some(serde_json::json!({"a": 1, "b": 0})),
7008            "writer B's field must not be silently merged into the persisted row: {:?}",
7009            final_entity.properties
7010        );
7011    }
7012
7013    /// Same race as `concurrent_entity_property_patches_from_one_revision_only_one_survives`,
7014    /// but driven entirely through the PRODUCTION entry point
7015    /// (`update_entity_with_embedding_report`) rather than the store's
7016    /// `replace_entity_if_unchanged` primitive directly. This closes a gap
7017    /// the primitive-level test cannot: it would still pass unchanged if the
7018    /// production caller were reverted to an unconditional write, since it
7019    /// never invokes that caller at all. Uses `race_seam::pause_after_read`
7020    /// (test-only, compiled out of non-test builds) to force both concurrent
7021    /// callers to observe the identical pre-write revision deterministically —
7022    /// no sleeps, no reliance on scheduler ordering.
7023    #[tokio::test]
7024    async fn production_update_entity_refuses_concurrent_stale_writer() {
7025        let rt = Arc::new(rt());
7026        let tok = NamespaceToken::local();
7027        let entity = rt
7028            .create_entity(
7029                &tok,
7030                "concept",
7031                None,
7032                "ProductionRaceTarget",
7033                None,
7034                Some(serde_json::json!({"a": 0, "b": 0})),
7035                vec![],
7036            )
7037            .await
7038            .expect("seed entity");
7039        let id = entity.id;
7040
7041        let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(2));
7042
7043        let writer_a = {
7044            let rt = Arc::clone(&rt);
7045            let tok = tok.clone();
7046            let barrier = std::sync::Arc::clone(&barrier);
7047            tokio::spawn(race_seam::AFTER_READ_BARRIER.scope(barrier, async move {
7048                rt.update_entity_with_embedding_report(
7049                    &tok,
7050                    id,
7051                    EntityPatch {
7052                        properties: Some(serde_json::json!({"a": 1})),
7053                        ..Default::default()
7054                    },
7055                )
7056                .await
7057            }))
7058        };
7059        let writer_b = {
7060            let rt = Arc::clone(&rt);
7061            let tok = tok.clone();
7062            let barrier = std::sync::Arc::clone(&barrier);
7063            tokio::spawn(race_seam::AFTER_READ_BARRIER.scope(barrier, async move {
7064                rt.update_entity_with_embedding_report(
7065                    &tok,
7066                    id,
7067                    EntityPatch {
7068                        properties: Some(serde_json::json!({"b": 1})),
7069                        ..Default::default()
7070                    },
7071                )
7072                .await
7073            }))
7074        };
7075
7076        let result_a = writer_a.await.expect("writer A task");
7077        let result_b = writer_b.await.expect("writer B task");
7078        let successes = [result_a.is_ok(), result_b.is_ok()]
7079            .into_iter()
7080            .filter(|ok| *ok)
7081            .count();
7082        assert_eq!(
7083            successes, 1,
7084            "exactly one production caller must win the race; the other must be refused: \
7085             a={result_a:?} b={result_b:?}"
7086        );
7087        let refused = if result_a.is_err() {
7088            result_a
7089        } else {
7090            result_b
7091        };
7092        match &refused {
7093            Err(RuntimeError::Khive(khive_error)) => {
7094                assert_eq!(
7095                    khive_error.kind(),
7096                    khive_types::ErrorKind::Conflict,
7097                    "the losing production caller must surface a typed conflict, not \
7098                     silently overwrite: {refused:?}"
7099                );
7100            }
7101            other => panic!("expected a typed conflict error, got {other:?}"),
7102        }
7103
7104        let final_entity = rt.get_entity(&tok, id).await.expect("read final entity");
7105        assert_ne!(
7106            final_entity.properties,
7107            Some(serde_json::json!({"a": 1, "b": 1})),
7108            "both racers' fields must never both land: that would mean the loser's stale \
7109             write silently succeeded"
7110        );
7111    }
7112
7113    /// Isolating fixture for the `AND ?8 > updated_at` conjunct of
7114    /// `entity_replace_if_unchanged_statement`.
7115    ///
7116    /// The concurrent-race tests above cannot cover it. What is measured, and
7117    /// deterministic: tautologizing `?8 > updated_at` alone reddened NOTHING in
7118    /// `khive-runtime` before this test existed, because the revision guard
7119    /// (`updated_at = ?13`) refuses the losing writer on its own whatever the
7120    /// clock did. Tautologizing `updated_at = ?13` + `deleted_at IS ?14`
7121    /// reddens only `production_update_entity_refuses_concurrent_stale_writer`,
7122    /// and defeating all three at once reddens the race tests.
7123    ///
7124    /// What is NOT claimed, because the fixture cannot support it: that the two
7125    /// guards are each independently sufficient. The race fixture pins the two
7126    /// racers' EXPECTED revisions equal but never their REPLACEMENT revisions,
7127    /// which both come from `max(now, expected + 1)`. So with `updated_at = ?13`
7128    /// removed, whether strict advance still refuses depends on which clock read
7129    /// won — a race, not a property of the fixture. This test strips the second
7130    /// mechanism by construction instead:
7131    /// it supplies the CORRECT expected revision and deletion marker, so
7132    /// `?13`/`?14` are satisfied by construction, and the ONLY thing that can
7133    /// refuse the write is the strict-advance conjunct.
7134    #[tokio::test]
7135    async fn entity_cas_refuses_a_replacement_revision_that_does_not_advance() {
7136        let rt = rt();
7137        let tok = NamespaceToken::local();
7138        let entity = rt
7139            .create_entity(
7140                &tok,
7141                "concept",
7142                None,
7143                "NonAdvancing",
7144                None,
7145                Some(serde_json::json!({"a": 0})),
7146                vec![],
7147            )
7148            .await
7149            .expect("seed entity");
7150        let id = entity.id;
7151
7152        let (mut replacement, _, _, expected_updated_at, expected_deleted_at) = rt
7153            .prepare_update_entity(
7154                &tok,
7155                id,
7156                EntityPatch {
7157                    properties: Some(serde_json::json!({"a": 1})),
7158                    ..Default::default()
7159                },
7160            )
7161            .await
7162            .expect("prepare update");
7163
7164        // Force the replacement revision to EQUAL the stored one, then PROVE the
7165        // isolation rather than asserting it in prose. Reading the row back is
7166        // what makes `?13` and `?14` observed facts here instead of values
7167        // carried out of `prepare_update_entity`'s setup read.
7168        replacement.updated_at = expected_updated_at;
7169
7170        let store = rt.entities(&tok).expect("entity store");
7171        let stored = store
7172            .get_entity_including_deleted(id)
7173            .await
7174            .expect("read stored row")
7175            .expect("row present before CAS");
7176        assert_eq!(
7177            stored.updated_at, expected_updated_at,
7178            "fixture premise: nothing moved the stored revision between prepare and CAS, \
7179             otherwise `?13` would refuse and this stops being an isolating fixture"
7180        );
7181        assert_eq!(
7182            stored.deleted_at, expected_deleted_at,
7183            "fixture premise: the stored deletion marker must still equal the snapshot's, \
7184             otherwise `deleted_at IS ?14` would refuse and this stops being an isolating \
7185             fixture"
7186        );
7187        assert_eq!(
7188            replacement.updated_at, stored.updated_at,
7189            "fixture premise: the replacement revision must NOT advance past the stored one, \
7190             which is the single condition under test"
7191        );
7192
7193        let committed = store
7194            .replace_entity_if_unchanged(replacement, expected_updated_at, expected_deleted_at)
7195            .await
7196            .expect("CAS query");
7197        assert!(
7198            !committed,
7199            "a replacement whose revision does not strictly advance past the stored one must \
7200             be refused: without `?8 > updated_at` the CAS would accept a write that leaves \
7201             `updated_at` unmoved, so a later writer holding the same snapshot would still \
7202             see its expected revision match and overwrite this one"
7203        );
7204
7205        let stored = rt.get_entity(&tok, id).await.expect("read back");
7206        assert_eq!(
7207            stored.properties,
7208            Some(serde_json::json!({"a": 0})),
7209            "the refused write must not have landed"
7210        );
7211    }
7212
7213    /// Isolate the deletion-marker guard from both timestamp and persisted
7214    /// version guards. Soft deletion leaves updated_at unchanged but advances
7215    /// version; this fixture deliberately supplies the current version with
7216    /// its stale pre-delete marker so dropping that marker alone permits an
7217    /// unintended resurrection.
7218    #[tokio::test]
7219    async fn entity_cas_refuses_a_stale_replacement_that_would_resurrect_a_tombstone() {
7220        let rt = rt();
7221        let tok = NamespaceToken::local();
7222        let entity = rt
7223            .create_entity(
7224                &tok,
7225                "concept",
7226                None,
7227                "Tombstoned",
7228                None,
7229                Some(serde_json::json!({"a": 0})),
7230                vec![],
7231            )
7232            .await
7233            .expect("seed entity");
7234        let id = entity.id;
7235
7236        // Snapshot BEFORE the delete: this is the stale writer's view.
7237        let (mut replacement, _, _, expected_updated_at, expected_deleted_at) = rt
7238            .prepare_update_entity(
7239                &tok,
7240                id,
7241                EntityPatch {
7242                    properties: Some(serde_json::json!({"a": 1})),
7243                    ..Default::default()
7244                },
7245            )
7246            .await
7247            .expect("prepare update");
7248        assert_eq!(
7249            expected_deleted_at, None,
7250            "fixture premise: the snapshot must be of a LIVE row"
7251        );
7252
7253        rt.delete_entity(&tok, id, false)
7254            .await
7255            .expect("soft delete");
7256
7257        let store = rt.entities(&tok).expect("entity store");
7258        let tombstoned = store
7259            .get_entity_including_deleted(id)
7260            .await
7261            .expect("read tombstone")
7262            .expect("row still present after soft delete");
7263
7264        // Isolate the deletion marker from the independent persisted-version
7265        // guard added in #2673: this test intentionally supplies the current
7266        // version while retaining the stale pre-delete marker.
7267        assert_eq!(tombstoned.version, replacement.version + 1);
7268        replacement.version = tombstoned.version;
7269
7270        // Prove the isolation rather than asserting it in prose. `?13` matches
7271        // because the soft delete left the revision alone, and `?8 > updated_at`
7272        // holds because the prepared replacement advanced past it. That leaves
7273        // `deleted_at IS ?14` as the only conjunct able to refuse the write.
7274        assert_eq!(
7275            tombstoned.updated_at, expected_updated_at,
7276            "fixture premise: soft delete must NOT move `updated_at`, otherwise \
7277             `?13` would refuse and this stops being an isolating fixture"
7278        );
7279        assert!(
7280            replacement.updated_at > tombstoned.updated_at,
7281            "fixture premise: the replacement revision must still advance, \
7282             otherwise `?8 > updated_at` would refuse and this stops being an \
7283             isolating fixture"
7284        );
7285        assert!(
7286            tombstoned.deleted_at.is_some(),
7287            "fixture premise: the row must actually be tombstoned"
7288        );
7289
7290        let committed = store
7291            .replace_entity_if_unchanged(replacement, expected_updated_at, expected_deleted_at)
7292            .await
7293            .expect("CAS query");
7294        assert!(
7295            !committed,
7296            "a replacement carrying a pre-delete snapshot must be refused after the row is \
7297             soft-deleted: without `deleted_at IS ?14` it would write `deleted_at = NULL` over \
7298             the tombstone and silently resurrect a deleted entity"
7299        );
7300
7301        let after = store
7302            .get_entity_including_deleted(id)
7303            .await
7304            .expect("read back")
7305            .expect("row present");
7306        assert!(
7307            after.deleted_at.is_some(),
7308            "the tombstone must survive the refused write"
7309        );
7310        assert_eq!(
7311            after.properties,
7312            Some(serde_json::json!({"a": 0})),
7313            "the refused write must not have landed"
7314        );
7315    }
7316
7317    /// Regression: the entity CAS requires the replacement revision to be
7318    /// STRICTLY greater than the stored one. If the replacement is computed
7319    /// as a raw `Utc::now()` read, a stored revision that is at or ahead of
7320    /// wall-clock time (a clock step backward, or — deterministically,
7321    /// reproduced here — a stored revision manufactured slightly ahead of
7322    /// "now") makes the new value fail to advance, and the CAS refuses a
7323    /// write with NO concurrent writer involved at all. The fix must clamp
7324    /// the replacement to `max(now, stored + 1)`; this test sets the stored
7325    /// revision one full second into the future (far outside normal clock
7326    /// skew) and asserts the update still succeeds instead of surfacing a
7327    /// spurious conflict.
7328    ///
7329    /// SCOPE: this is a revision-clamp test, NOT CAS regression coverage. Its
7330    /// assertion is that the write SUCCEEDS, which an unconditional UPDATE
7331    /// also satisfies, so it stays green if the `updated_at = ?13` /
7332    /// `?8 > updated_at` guard is dropped entirely. The guard's regression
7333    /// coverage is
7334    /// `concurrent_entity_property_patches_from_one_revision_only_one_survives`
7335    /// (primitive) and `production_update_entity_refuses_concurrent_stale_writer`
7336    /// (production wiring); do not count this test toward it.
7337    #[tokio::test]
7338    async fn update_entity_succeeds_when_stored_revision_is_ahead_of_wall_clock() {
7339        let rt = rt();
7340        let tok = NamespaceToken::local();
7341        let entity = rt
7342            .create_entity(
7343                &tok,
7344                "concept",
7345                None,
7346                "FutureRevisionTarget",
7347                None,
7348                None,
7349                vec![],
7350            )
7351            .await
7352            .expect("seed entity");
7353        let id = entity.id;
7354
7355        let future_micros = chrono::Utc::now().timestamp_micros() + 1_000_000;
7356        let pool = rt.backend().pool_arc();
7357        let id_str = id.to_string();
7358        tokio::task::spawn_blocking(move || {
7359            let guard = pool.writer().expect("writer connection");
7360            guard
7361                .execute(
7362                    "UPDATE entities SET version = version + 1, updated_at = ?1 WHERE id = ?2",
7363                    rusqlite::params![future_micros, id_str],
7364                )
7365                .expect("force future revision")
7366        })
7367        .await
7368        .expect("join");
7369
7370        let updated = rt
7371            .update_entity(
7372                &tok,
7373                id,
7374                EntityPatch {
7375                    description: Some(Some("patched after a forced future revision".to_string())),
7376                    ..Default::default()
7377                },
7378            )
7379            .await
7380            .expect(
7381                "update must succeed and advance past the stored revision, not report a \
7382                 spurious conflict when nothing else wrote to this row",
7383            );
7384        assert!(updated.updated_at > future_micros);
7385    }
7386
7387    #[tokio::test]
7388    async fn update_entity_clear_description_with_some_none() {
7389        let rt = rt();
7390        let tok = NamespaceToken::local();
7391        let entity = rt
7392            .create_entity(
7393                &tok,
7394                "concept",
7395                None,
7396                "ClearDesc",
7397                Some("has description"),
7398                None,
7399                vec![],
7400            )
7401            .await
7402            .unwrap();
7403
7404        let updated = rt
7405            .update_entity(
7406                &tok,
7407                entity.id,
7408                EntityPatch {
7409                    description: Some(None),
7410                    ..Default::default()
7411                },
7412            )
7413            .await
7414            .unwrap();
7415
7416        assert!(
7417            updated.description.is_none(),
7418            "description should be cleared"
7419        );
7420    }
7421
7422    #[tokio::test]
7423    async fn update_entity_reindexes_when_name_changes() {
7424        let rt = rt();
7425        let tok = NamespaceToken::local();
7426        let entity = rt
7427            .create_entity(&tok, "concept", None, "OldName", None, None, vec![])
7428            .await
7429            .unwrap();
7430
7431        let hits_before = fts_hit(&rt, &tok, "OldName").await;
7432        assert!(
7433            hits_before.contains(&entity.id),
7434            "entity should be findable by old name"
7435        );
7436
7437        rt.update_entity(
7438            &tok,
7439            entity.id,
7440            EntityPatch {
7441                name: Some("NewName".to_string()),
7442                ..Default::default()
7443            },
7444        )
7445        .await
7446        .unwrap();
7447
7448        let hits_old = fts_hit(&rt, &tok, "OldName").await;
7449        let hits_new = fts_hit(&rt, &tok, "NewName").await;
7450
7451        assert!(
7452            !hits_old.contains(&entity.id),
7453            "old name should no longer match after rename"
7454        );
7455        assert!(
7456            hits_new.contains(&entity.id),
7457            "new name should be findable after rename"
7458        );
7459    }
7460
7461    #[tokio::test]
7462    async fn update_entity_properties_merges_preserving_existing_keys() {
7463        let rt = rt();
7464        let tok = NamespaceToken::local();
7465        let entity = rt
7466            .create_entity(
7467                &tok,
7468                "concept",
7469                None,
7470                "MergeProps",
7471                None,
7472                Some(serde_json::json!({
7473                    "domain": "inference",
7474                    "repo": "lattice",
7475                    "status": "researched",
7476                })),
7477                vec![],
7478            )
7479            .await
7480            .unwrap();
7481
7482        let updated = rt
7483            .update_entity(
7484                &tok,
7485                entity.id,
7486                EntityPatch {
7487                    properties: Some(serde_json::json!({"status": "implemented"})),
7488                    ..Default::default()
7489                },
7490            )
7491            .await
7492            .unwrap();
7493
7494        let props = updated.properties.expect("properties should remain set");
7495        assert_eq!(props["domain"], "inference", "domain key must be preserved");
7496        assert_eq!(props["repo"], "lattice", "repo key must be preserved");
7497        assert_eq!(
7498            props["status"], "implemented",
7499            "status key must be updated by patch"
7500        );
7501    }
7502
7503    #[tokio::test]
7504    async fn update_entity_skips_reindex_when_only_properties_change() {
7505        let rt = rt();
7506        let tok = NamespaceToken::local();
7507        let entity = rt
7508            .create_entity(&tok, "concept", None, "StableIndexed", None, None, vec![])
7509            .await
7510            .unwrap();
7511
7512        let hits_before = fts_hit(&rt, &tok, "StableIndexed").await;
7513        assert!(hits_before.contains(&entity.id));
7514
7515        rt.update_entity(
7516            &tok,
7517            entity.id,
7518            EntityPatch {
7519                properties: Some(serde_json::json!({"new": "prop"})),
7520                ..Default::default()
7521            },
7522        )
7523        .await
7524        .unwrap();
7525
7526        let hits_after = fts_hit(&rt, &tok, "StableIndexed").await;
7527        assert!(
7528            hits_after.contains(&entity.id),
7529            "still findable after props-only patch"
7530        );
7531    }
7532
7533    #[tokio::test]
7534    async fn merge_entity_rewires_edges() {
7535        let rt = rt();
7536        let tok = NamespaceToken::local();
7537        let a = rt
7538            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7539            .await
7540            .unwrap();
7541        let b = rt
7542            .create_entity(&tok, "concept", None, "B", None, None, vec![])
7543            .await
7544            .unwrap();
7545        let c = rt
7546            .create_entity(&tok, "concept", None, "C", None, None, vec![])
7547            .await
7548            .unwrap();
7549        let d = rt
7550            .create_entity(&tok, "concept", None, "D", None, None, vec![])
7551            .await
7552            .unwrap();
7553
7554        // A→B and C→B; merge B into D → should become A→D and C→D.
7555        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7556            .await
7557            .unwrap();
7558        rt.link(&tok, c.id, b.id, EdgeRelation::Extends, 1.0, None)
7559            .await
7560            .unwrap();
7561
7562        let summary = rt
7563            .merge_entity_with_reason(
7564                &tok,
7565                d.id,
7566                b.id,
7567                EntityDedupMergePolicy::PreferInto,
7568                ContentMergeStrategy::Append,
7569                false,
7570                None,
7571            )
7572            .await
7573            .unwrap();
7574
7575        assert_eq!(summary.kept_id, d.id);
7576        assert_eq!(summary.removed_id, b.id);
7577        assert_eq!(summary.edges_rewired, 2);
7578
7579        let a_neighbors = rt
7580            .neighbors(&tok, a.id, Direction::Out, None, None)
7581            .await
7582            .unwrap();
7583        assert_eq!(a_neighbors.len(), 1);
7584        assert_eq!(a_neighbors[0].node_id, d.id);
7585
7586        let c_neighbors = rt
7587            .neighbors(&tok, c.id, Direction::Out, None, None)
7588            .await
7589            .unwrap();
7590        assert_eq!(c_neighbors.len(), 1);
7591        assert_eq!(c_neighbors[0].node_id, d.id);
7592    }
7593
7594    // khive#1236: edges incident to `from_id` but stamped with a namespace other
7595    // than the merge caller's must still be discovered and rewired — by-ID edge
7596    // endpoints are namespace-agnostic (ADR-007 Rev 6), and `link` stamps an edge
7597    // with its *creator's* namespace, not either endpoint's.
7598    #[tokio::test]
7599    async fn merge_entity_rewires_edges_from_other_namespaces() {
7600        use crate::Namespace;
7601
7602        let rt = rt();
7603        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
7604        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
7605
7606        let into_a = rt
7607            .create_entity(&ns_a, "concept", None, "Into A", None, None, vec![])
7608            .await
7609            .unwrap();
7610        let from_a = rt
7611            .create_entity(&ns_a, "concept", None, "From A", None, None, vec![])
7612            .await
7613            .unwrap();
7614        let foreign_b = rt
7615            .create_entity(&ns_b, "concept", None, "Foreign B", None, None, vec![])
7616            .await
7617            .unwrap();
7618
7619        // Edge created by an ns_b caller, stamped with ns_b, whose target lives in
7620        // ns_a — legal because by-ID link endpoints are namespace-agnostic.
7621        rt.link(
7622            &ns_b,
7623            foreign_b.id,
7624            from_a.id,
7625            EdgeRelation::Extends,
7626            1.0,
7627            None,
7628        )
7629        .await
7630        .unwrap();
7631
7632        let summary = rt
7633            .merge_entity_with_reason(
7634                &ns_a,
7635                into_a.id,
7636                from_a.id,
7637                EntityDedupMergePolicy::PreferInto,
7638                ContentMergeStrategy::Append,
7639                false,
7640                None,
7641            )
7642            .await
7643            .unwrap();
7644
7645        assert_eq!(
7646            summary.edges_rewired, 1,
7647            "the ns_b-stamped edge incident to from_id must be discovered and rewired, not missed"
7648        );
7649
7650        let foreign_neighbors = rt
7651            .neighbors(&ns_b, foreign_b.id, Direction::Out, None, None)
7652            .await
7653            .unwrap();
7654        assert_eq!(
7655            foreign_neighbors.len(),
7656            1,
7657            "cross-namespace edge must survive the merge, rewired to point at into_id"
7658        );
7659        assert_eq!(foreign_neighbors[0].node_id, into_a.id);
7660    }
7661
7662    // khive#1216: a merge rewire must re-check the pack endpoint contract for the
7663    // POST-rewrite pair, not just carry the pre-merge edge over. into_id and
7664    // from_id share `kind` (enforced by the caller) but may differ in
7665    // `entity_type`, so a pack rule scoped via `EntityOfType` can accept
7666    // `from_id`'s edge yet reject the identical relation once rewritten onto
7667    // `into_id`.
7668    #[tokio::test]
7669    async fn merge_entity_drops_edge_violating_endpoint_contract_after_rewire() {
7670        let rt = rt();
7671        let tok = NamespaceToken::local();
7672
7673        // depends_on is NOT in the base concept->concept allowlist; only this
7674        // pack rule (theorem -> definition) accepts it.
7675        rt.install_edge_rules(vec![EdgeEndpointRule {
7676            relation: EdgeRelation::DependsOn,
7677            source: EndpointKind::EntityOfType {
7678                kind: "concept",
7679                entity_type: "theorem",
7680            },
7681            target: EndpointKind::EntityOfType {
7682                kind: "concept",
7683                entity_type: "definition",
7684            },
7685        }]);
7686
7687        let def_entity = rt
7688            .create_entity(
7689                &tok,
7690                "concept",
7691                Some("definition"),
7692                "Def",
7693                None,
7694                None,
7695                vec![],
7696            )
7697            .await
7698            .unwrap();
7699        let from_theorem = rt
7700            .create_entity(
7701                &tok,
7702                "concept",
7703                Some("theorem"),
7704                "FromTheorem",
7705                None,
7706                None,
7707                vec![],
7708            )
7709            .await
7710            .unwrap();
7711        // Same base kind ("concept") as from_theorem, but a different entity_type
7712        // — the merge's same-kind constraint allows this, the endpoint contract
7713        // (entity_type-scoped) does not.
7714        let into_lemma = rt
7715            .create_entity(
7716                &tok,
7717                "concept",
7718                Some("lemma"),
7719                "IntoLemma",
7720                None,
7721                None,
7722                vec![],
7723            )
7724            .await
7725            .unwrap();
7726
7727        rt.link(
7728            &tok,
7729            from_theorem.id,
7730            def_entity.id,
7731            EdgeRelation::DependsOn,
7732            1.0,
7733            None,
7734        )
7735        .await
7736        .unwrap();
7737
7738        let summary = rt
7739            .merge_entity_with_reason(
7740                &tok,
7741                into_lemma.id,
7742                from_theorem.id,
7743                EntityDedupMergePolicy::PreferInto,
7744                ContentMergeStrategy::Append,
7745                false,
7746                None,
7747            )
7748            .await
7749            .unwrap();
7750
7751        assert_eq!(
7752            summary.edges_rewired, 0,
7753            "the contract-violating rewire must not be counted as rewired"
7754        );
7755        assert_eq!(
7756            summary.edges_contract_skipped, 1,
7757            "the depends_on edge must be dropped, not silently rewritten past the endpoint contract"
7758        );
7759
7760        let def_neighbors = rt
7761            .neighbors(&tok, def_entity.id, Direction::In, None, None)
7762            .await
7763            .unwrap();
7764        assert!(
7765            def_neighbors.is_empty(),
7766            "no contract-violating depends_on edge should survive onto into_lemma; got {def_neighbors:?}"
7767        );
7768    }
7769
7770    // Dry-run counterpart: a contract-violating rewire must be predicted as
7771    // skipped (not rewired), and no write occurs.
7772    #[tokio::test]
7773    async fn merge_entity_dry_run_predicts_contract_skip_without_writing() {
7774        let rt = rt();
7775        let tok = NamespaceToken::local();
7776
7777        rt.install_edge_rules(vec![EdgeEndpointRule {
7778            relation: EdgeRelation::DependsOn,
7779            source: EndpointKind::EntityOfType {
7780                kind: "concept",
7781                entity_type: "theorem",
7782            },
7783            target: EndpointKind::EntityOfType {
7784                kind: "concept",
7785                entity_type: "definition",
7786            },
7787        }]);
7788
7789        let def_entity = rt
7790            .create_entity(
7791                &tok,
7792                "concept",
7793                Some("definition"),
7794                "Def",
7795                None,
7796                None,
7797                vec![],
7798            )
7799            .await
7800            .unwrap();
7801        let from_theorem = rt
7802            .create_entity(
7803                &tok,
7804                "concept",
7805                Some("theorem"),
7806                "FromTheorem",
7807                None,
7808                None,
7809                vec![],
7810            )
7811            .await
7812            .unwrap();
7813        let into_lemma = rt
7814            .create_entity(
7815                &tok,
7816                "concept",
7817                Some("lemma"),
7818                "IntoLemma",
7819                None,
7820                None,
7821                vec![],
7822            )
7823            .await
7824            .unwrap();
7825
7826        rt.link(
7827            &tok,
7828            from_theorem.id,
7829            def_entity.id,
7830            EdgeRelation::DependsOn,
7831            1.0,
7832            None,
7833        )
7834        .await
7835        .unwrap();
7836
7837        let summary = rt
7838            .merge_entity_with_reason(
7839                &tok,
7840                into_lemma.id,
7841                from_theorem.id,
7842                EntityDedupMergePolicy::PreferInto,
7843                ContentMergeStrategy::Append,
7844                true, // dry_run
7845                None,
7846            )
7847            .await
7848            .unwrap();
7849
7850        assert_eq!(summary.edges_rewired, 0);
7851        assert_eq!(summary.edges_contract_skipped, 1);
7852
7853        // Nothing written: the original edge is untouched.
7854        let def_neighbors = rt
7855            .neighbors(&tok, def_entity.id, Direction::In, None, None)
7856            .await
7857            .unwrap();
7858        assert_eq!(def_neighbors.len(), 1);
7859        assert_eq!(def_neighbors[0].node_id, from_theorem.id);
7860    }
7861
7862    // A conflicting rewire must leave the surviving edge untouched (ADR-039
7863    // DO NOTHING) — the merged-from edge's attributes never overwrite it.
7864    #[tokio::test]
7865    async fn merge_entity_conflict_keeps_survivor_edge_attributes() {
7866        let rt = rt();
7867        let tok = NamespaceToken::local();
7868        let into = rt
7869            .create_entity(&tok, "concept", None, "Into", None, None, vec![])
7870            .await
7871            .unwrap();
7872        let from = rt
7873            .create_entity(&tok, "concept", None, "From", None, None, vec![])
7874            .await
7875            .unwrap();
7876        let shared = rt
7877            .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
7878            .await
7879            .unwrap();
7880
7881        let survivor = rt
7882            .link(&tok, into.id, shared.id, EdgeRelation::Extends, 0.9, None)
7883            .await
7884            .unwrap();
7885        rt.link(&tok, from.id, shared.id, EdgeRelation::Extends, 0.2, None)
7886            .await
7887            .unwrap();
7888
7889        rt.merge_entity(
7890            &tok,
7891            into.id,
7892            from.id,
7893            EntityDedupMergePolicy::PreferInto,
7894            ContentMergeStrategy::Append,
7895            false,
7896        )
7897        .await
7898        .unwrap();
7899
7900        let edges = rt
7901            .list_edges(
7902                &tok,
7903                crate::EdgeListFilter {
7904                    source_id: Some(into.id),
7905                    target_id: Some(shared.id),
7906                    relations: vec![EdgeRelation::Extends],
7907                    ..Default::default()
7908                },
7909                10,
7910                0,
7911            )
7912            .await
7913            .unwrap();
7914        assert_eq!(edges.len(), 1);
7915        assert_eq!(edges[0].id, survivor.id);
7916        assert!(
7917            (edges[0].weight - 0.9).abs() < f64::EPSILON,
7918            "survivor weight must not be overwritten by the merged-from edge; got {}",
7919            edges[0].weight
7920        );
7921    }
7922
7923    #[tokio::test]
7924    async fn merge_entity_conflict_records_restorable_edge_and_annotation_preimages() {
7925        let rt = rt();
7926        let tok = NamespaceToken::local();
7927        let into = rt
7928            .create_entity(&tok, "concept", None, "Into", None, None, vec![])
7929            .await
7930            .unwrap();
7931        let from = rt
7932            .create_entity(&tok, "concept", None, "From", None, None, vec![])
7933            .await
7934            .unwrap();
7935        let shared = rt
7936            .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
7937            .await
7938            .unwrap();
7939        let annotator = rt
7940            .create_note(
7941                &tok,
7942                "observation",
7943                None,
7944                "edge judgment",
7945                None,
7946                None,
7947                vec![],
7948            )
7949            .await
7950            .unwrap();
7951        let nested_annotator = rt
7952            .create_note(
7953                &tok,
7954                "observation",
7955                None,
7956                "judgment review",
7957                None,
7958                None,
7959                vec![],
7960            )
7961            .await
7962            .unwrap();
7963
7964        let survivor = rt
7965            .link(
7966                &tok,
7967                into.id,
7968                shared.id,
7969                EdgeRelation::Extends,
7970                0.9,
7971                Some(serde_json::json!({"source": "survivor"})),
7972            )
7973            .await
7974            .unwrap();
7975        let dropped = rt
7976            .link(
7977                &tok,
7978                from.id,
7979                shared.id,
7980                EdgeRelation::Extends,
7981                0.2,
7982                Some(serde_json::json!({"source": "dropped"})),
7983            )
7984            .await
7985            .unwrap();
7986        let annotation = rt
7987            .link(
7988                &tok,
7989                annotator.id,
7990                dropped.id.into(),
7991                EdgeRelation::Annotates,
7992                0.7,
7993                Some(serde_json::json!({"basis": "manual"})),
7994            )
7995            .await
7996            .unwrap();
7997        let nested_annotation = rt
7998            .link(
7999                &tok,
8000                nested_annotator.id,
8001                annotation.id.into(),
8002                EdgeRelation::Annotates,
8003                0.6,
8004                Some(serde_json::json!({"review": "confirmed"})),
8005            )
8006            .await
8007            .unwrap();
8008        rt.delete_edge(&tok, annotation.id.into(), false)
8009            .await
8010            .unwrap();
8011
8012        let summary = rt
8013            .merge_entity(
8014                &tok,
8015                into.id,
8016                from.id,
8017                EntityDedupMergePolicy::PreferInto,
8018                ContentMergeStrategy::Append,
8019                false,
8020            )
8021            .await
8022            .unwrap();
8023
8024        let [conflict] = summary.edge_conflict_preimages.as_slice() else {
8025            panic!(
8026                "expected one edge-conflict preimage, got {:?}",
8027                summary.edge_conflict_preimages
8028            );
8029        };
8030        assert_eq!(conflict.surviving_edge_id, Uuid::from(survivor.id));
8031        assert_eq!(conflict.dropped_edge.id, Uuid::from(dropped.id));
8032        assert_eq!(conflict.dropped_edge.source_id, from.id);
8033        assert_eq!(conflict.dropped_edge.target_id, shared.id);
8034        assert_eq!(conflict.dropped_edge.relation, "extends");
8035        assert_eq!(conflict.dropped_edge.weight, 0.2);
8036        assert_eq!(
8037            conflict.dropped_edge.metadata,
8038            Some(serde_json::json!({"source": "dropped"}))
8039        );
8040        assert_eq!(conflict.incident_edge_preimages.len(), 2);
8041        assert_eq!(
8042            conflict.incident_edge_preimages[0].id,
8043            Uuid::from(annotation.id)
8044        );
8045        assert_eq!(
8046            conflict.incident_edge_preimages[1].id,
8047            Uuid::from(nested_annotation.id)
8048        );
8049
8050        for id in [dropped.id, annotation.id, nested_annotation.id] {
8051            assert!(
8052                rt.get_edge_including_deleted(&tok, id.into())
8053                    .await
8054                    .unwrap()
8055                    .is_none(),
8056                "merge conflict cascade must leave no dangling edge row for {id}"
8057            );
8058        }
8059
8060        let events = rt
8061            .events(&tok)
8062            .unwrap()
8063            .query_events(
8064                khive_storage::EventFilter {
8065                    kinds: vec![EventKind::EntityMerged],
8066                    ..Default::default()
8067                },
8068                khive_storage::types::PageRequest {
8069                    offset: 0,
8070                    limit: 10,
8071                },
8072            )
8073            .await
8074            .unwrap();
8075        assert_eq!(events.items.len(), 1);
8076        assert_eq!(
8077            events.items[0].payload["edge_conflict_preimages"],
8078            serde_json::to_value(&summary.edge_conflict_preimages).unwrap()
8079        );
8080
8081        restore_edge_preimage(&rt, &tok, &conflict.dropped_edge).await;
8082        for preimage in &conflict.incident_edge_preimages {
8083            restore_edge_preimage(&rt, &tok, preimage).await;
8084        }
8085        for preimage in
8086            std::iter::once(&conflict.dropped_edge).chain(conflict.incident_edge_preimages.iter())
8087        {
8088            let restored = rt
8089                .get_edge_including_deleted(&tok, preimage.id)
8090                .await
8091                .unwrap()
8092                .expect("restored edge");
8093            assert_edge_matches_preimage(&restored, preimage);
8094        }
8095    }
8096
8097    // A dry run must predict the same conflict preimages a committing merge
8098    // would produce, without deleting or mutating a single row. The incident
8099    // cascade is two levels deep (an annotation on the dropped edge, and a
8100    // nested annotation on that annotation) so the root-to-leaf ordering
8101    // ADR-014 promises is actually exercised, not just a one-element vec that
8102    // trivially satisfies any order. Every row touched by the merge — both
8103    // entities and every edge — is snapshotted before the dry run and
8104    // compared field-for-field against its post-run state.
8105    #[tokio::test]
8106    async fn merge_entity_dry_run_conflict_returns_preimages_without_mutating() {
8107        let rt = rt();
8108        let tok = NamespaceToken::local();
8109        let into = rt
8110            .create_entity(&tok, "concept", None, "Into", None, None, vec![])
8111            .await
8112            .unwrap();
8113        let from = rt
8114            .create_entity(&tok, "concept", None, "From", None, None, vec![])
8115            .await
8116            .unwrap();
8117        let shared = rt
8118            .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
8119            .await
8120            .unwrap();
8121        let annotator = rt
8122            .create_note(
8123                &tok,
8124                "observation",
8125                None,
8126                "edge judgment",
8127                None,
8128                None,
8129                vec![],
8130            )
8131            .await
8132            .unwrap();
8133        let nested_annotator = rt
8134            .create_note(
8135                &tok,
8136                "observation",
8137                None,
8138                "judgment review",
8139                None,
8140                None,
8141                vec![],
8142            )
8143            .await
8144            .unwrap();
8145
8146        let survivor = rt
8147            .link(
8148                &tok,
8149                into.id,
8150                shared.id,
8151                EdgeRelation::Extends,
8152                0.9,
8153                Some(serde_json::json!({"source": "survivor"})),
8154            )
8155            .await
8156            .unwrap();
8157        let dropped = rt
8158            .link(
8159                &tok,
8160                from.id,
8161                shared.id,
8162                EdgeRelation::Extends,
8163                0.2,
8164                Some(serde_json::json!({"source": "dropped"})),
8165            )
8166            .await
8167            .unwrap();
8168        let annotation = rt
8169            .link(
8170                &tok,
8171                annotator.id,
8172                dropped.id.into(),
8173                EdgeRelation::Annotates,
8174                0.7,
8175                Some(serde_json::json!({"basis": "manual"})),
8176            )
8177            .await
8178            .unwrap();
8179        let nested_annotation = rt
8180            .link(
8181                &tok,
8182                nested_annotator.id,
8183                annotation.id.into(),
8184                EdgeRelation::Annotates,
8185                0.6,
8186                Some(serde_json::json!({"basis": "nested"})),
8187            )
8188            .await
8189            .unwrap();
8190        rt.delete_edge(&tok, nested_annotation.id.into(), false)
8191            .await
8192            .unwrap();
8193
8194        let survivor_before = rt
8195            .get_edge_including_deleted(&tok, survivor.id.into())
8196            .await
8197            .unwrap()
8198            .expect("survivor edge exists");
8199        let dropped_before = rt
8200            .get_edge_including_deleted(&tok, dropped.id.into())
8201            .await
8202            .unwrap()
8203            .expect("dropped edge exists");
8204        let annotation_before = rt
8205            .get_edge_including_deleted(&tok, annotation.id.into())
8206            .await
8207            .unwrap()
8208            .expect("annotation edge exists");
8209        let nested_annotation_before = rt
8210            .get_edge_including_deleted(&tok, nested_annotation.id.into())
8211            .await
8212            .unwrap()
8213            .expect("nested annotation edge exists");
8214        let into_before = rt
8215            .get_entity(&tok, into.id)
8216            .await
8217            .expect("into entity exists");
8218        let from_before = rt
8219            .get_entity(&tok, from.id)
8220            .await
8221            .expect("from entity exists");
8222
8223        let summary = rt
8224            .merge_entity(
8225                &tok,
8226                into.id,
8227                from.id,
8228                EntityDedupMergePolicy::PreferInto,
8229                ContentMergeStrategy::Append,
8230                true,
8231            )
8232            .await
8233            .unwrap();
8234
8235        let [conflict] = summary.edge_conflict_preimages.as_slice() else {
8236            panic!(
8237                "expected one edge-conflict preimage from the dry run, got {:?}",
8238                summary.edge_conflict_preimages
8239            );
8240        };
8241        assert_eq!(conflict.surviving_edge_id, Uuid::from(survivor.id));
8242        assert_eq!(conflict.dropped_edge.id, Uuid::from(dropped.id));
8243        assert_eq!(conflict.dropped_edge.source_id, from.id);
8244        assert_eq!(conflict.dropped_edge.target_id, shared.id);
8245        assert_eq!(conflict.dropped_edge.weight, 0.2);
8246        // Root-to-leaf order (ADR-014): the direct annotation on the dropped
8247        // edge must precede the annotation nested on top of it.
8248        assert_eq!(conflict.incident_edge_preimages.len(), 2);
8249        assert_eq!(
8250            conflict.incident_edge_preimages[0].id,
8251            Uuid::from(annotation.id)
8252        );
8253        assert!(
8254            conflict.incident_edge_preimages[0].deleted_at.is_none(),
8255            "the direct annotation was never soft-deleted"
8256        );
8257        assert_eq!(
8258            conflict.incident_edge_preimages[1].id,
8259            Uuid::from(nested_annotation.id)
8260        );
8261        assert!(
8262            conflict.incident_edge_preimages[1].deleted_at.is_some(),
8263            "dry-run preimage must retain the nested annotation's tombstone state"
8264        );
8265
8266        let survivor_after = rt
8267            .get_edge_including_deleted(&tok, survivor.id.into())
8268            .await
8269            .unwrap()
8270            .expect("dry run must not delete the survivor edge");
8271        let dropped_after = rt
8272            .get_edge_including_deleted(&tok, dropped.id.into())
8273            .await
8274            .unwrap()
8275            .expect("dry run must not delete the dropped edge");
8276        let annotation_after = rt
8277            .get_edge_including_deleted(&tok, annotation.id.into())
8278            .await
8279            .unwrap()
8280            .expect("dry run must not delete the cascaded annotation");
8281        let nested_annotation_after = rt
8282            .get_edge_including_deleted(&tok, nested_annotation.id.into())
8283            .await
8284            .unwrap()
8285            .expect("dry run must not delete the nested cascaded annotation");
8286        assert_eq!(
8287            serde_json::to_value(&survivor_before).unwrap(),
8288            serde_json::to_value(&survivor_after).unwrap(),
8289            "dry run must not mutate the surviving edge's row at all"
8290        );
8291        assert_eq!(
8292            serde_json::to_value(&dropped_before).unwrap(),
8293            serde_json::to_value(&dropped_after).unwrap(),
8294            "dry run must not mutate the would-be-dropped edge's row at all"
8295        );
8296        assert_eq!(
8297            serde_json::to_value(&annotation_before).unwrap(),
8298            serde_json::to_value(&annotation_after).unwrap(),
8299            "dry run must not mutate the incident annotation's row at all"
8300        );
8301        assert_eq!(
8302            serde_json::to_value(&nested_annotation_before).unwrap(),
8303            serde_json::to_value(&nested_annotation_after).unwrap(),
8304            "dry run must not mutate the nested incident annotation's row at all"
8305        );
8306
8307        let into_after = rt
8308            .get_entity(&tok, into.id)
8309            .await
8310            .expect("into entity must remain unmerged after a dry run");
8311        let from_after = rt
8312            .get_entity(&tok, from.id)
8313            .await
8314            .expect("from entity must not be merged away by a dry run");
8315        assert_eq!(
8316            serde_json::to_value(&into_before).unwrap(),
8317            serde_json::to_value(&into_after).unwrap(),
8318            "dry run must not mutate the into entity's row at all"
8319        );
8320        assert_eq!(
8321            serde_json::to_value(&from_before).unwrap(),
8322            serde_json::to_value(&from_after).unwrap(),
8323            "dry run must not mutate the from entity's row at all"
8324        );
8325        assert_eq!(from_after.deleted_at, None);
8326        assert_eq!(from_after.merged_into, None);
8327        assert_eq!(from_after.merge_event_id, None);
8328
8329        let events = rt
8330            .events(&tok)
8331            .unwrap()
8332            .query_events(
8333                khive_storage::EventFilter {
8334                    kinds: vec![EventKind::EntityMerged],
8335                    ..Default::default()
8336                },
8337                khive_storage::types::PageRequest {
8338                    offset: 0,
8339                    limit: 10,
8340                },
8341            )
8342            .await
8343            .unwrap();
8344        assert!(
8345            events.items.is_empty(),
8346            "a dry run must not record a merge audit event"
8347        );
8348    }
8349
8350    // A soft-deleted surviving edge must not be resurrected by a conflicting
8351    // rewire — the from-edge is dropped and the tombstone stays.
8352    #[tokio::test]
8353    async fn merge_entity_conflict_does_not_resurrect_tombstoned_edge() {
8354        let rt = rt();
8355        let tok = NamespaceToken::local();
8356        let into = rt
8357            .create_entity(&tok, "concept", None, "Into", None, None, vec![])
8358            .await
8359            .unwrap();
8360        let from = rt
8361            .create_entity(&tok, "concept", None, "From", None, None, vec![])
8362            .await
8363            .unwrap();
8364        let shared = rt
8365            .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
8366            .await
8367            .unwrap();
8368
8369        let survivor = rt
8370            .link(&tok, into.id, shared.id, EdgeRelation::Extends, 0.9, None)
8371            .await
8372            .unwrap();
8373        rt.delete_edge(&tok, survivor.id.into(), false)
8374            .await
8375            .unwrap();
8376        rt.link(&tok, from.id, shared.id, EdgeRelation::Extends, 0.2, None)
8377            .await
8378            .unwrap();
8379
8380        rt.merge_entity(
8381            &tok,
8382            into.id,
8383            from.id,
8384            EntityDedupMergePolicy::PreferInto,
8385            ContentMergeStrategy::Append,
8386            false,
8387        )
8388        .await
8389        .unwrap();
8390
8391        for (src, label) in [(into.id, "into"), (from.id, "from")] {
8392            let edges = rt
8393                .list_edges(
8394                    &tok,
8395                    crate::EdgeListFilter {
8396                        source_id: Some(src),
8397                        target_id: Some(shared.id),
8398                        relations: vec![EdgeRelation::Extends],
8399                        ..Default::default()
8400                    },
8401                    10,
8402                    0,
8403                )
8404                .await
8405                .unwrap();
8406            assert!(
8407                edges.is_empty(),
8408                "no live {label}→shared edge may exist after merging over a tombstone; got: {edges:?}"
8409            );
8410        }
8411    }
8412
8413    // The survivor row write must not null columns it doesn't merge —
8414    // entity_type (and the old entity-owned content_ref) were lost by the old full-row
8415    // INSERT OR REPLACE.
8416    #[tokio::test]
8417    async fn merge_entity_preserves_survivor_entity_type() {
8418        let rt = rt();
8419        let tok = NamespaceToken::local();
8420        let into = rt
8421            .create_entity(&tok, "resource", Some("skill"), "Into", None, None, vec![])
8422            .await
8423            .unwrap();
8424        assert_eq!(into.entity_type.as_deref(), Some("skill"));
8425        let from = rt
8426            .create_entity(&tok, "resource", None, "From", None, None, vec![])
8427            .await
8428            .unwrap();
8429
8430        rt.merge_entity(
8431            &tok,
8432            into.id,
8433            from.id,
8434            EntityDedupMergePolicy::PreferInto,
8435            ContentMergeStrategy::Append,
8436            false,
8437        )
8438        .await
8439        .unwrap();
8440
8441        let got = rt.get_entity(&tok, into.id).await.unwrap();
8442        assert_eq!(
8443            got.entity_type.as_deref(),
8444            Some("skill"),
8445            "merge must not null the survivor's entity_type"
8446        );
8447    }
8448
8449    #[tokio::test]
8450    async fn merge_entity_preserves_survivor_content_ref() {
8451        let rt = rt();
8452        let tok = NamespaceToken::local();
8453        let into = rt
8454            .create_entity(&tok, "document", None, "Into", None, None, vec![])
8455            .await
8456            .unwrap();
8457        let from = rt
8458            .create_entity(&tok, "document", None, "From", None, None, vec![])
8459            .await
8460            .unwrap();
8461
8462        let content_ref = khive_storage::ContentRef::from_hex("0".repeat(64)).unwrap();
8463        let store = rt.entities(&tok).unwrap();
8464        rt.attachments()
8465            .unwrap()
8466            .upsert_attachment(khive_storage::Attachment::from_new(
8467                into.id,
8468                khive_storage::AttachmentSubstrate::Entity,
8469                khive_storage::NewAttachment {
8470                    role: "content".to_string(),
8471                    content_ref: content_ref.clone(),
8472                    media_type: None,
8473                    size_bytes: None,
8474                },
8475                into.created_at,
8476            ))
8477            .await
8478            .unwrap();
8479
8480        rt.merge_entity(
8481            &tok,
8482            into.id,
8483            from.id,
8484            EntityDedupMergePolicy::PreferInto,
8485            ContentMergeStrategy::Append,
8486            false,
8487        )
8488        .await
8489        .unwrap();
8490
8491        let got = store.get_entity(into.id).await.unwrap().unwrap();
8492        assert_eq!(
8493            got.content_ref.as_deref(),
8494            Some(content_ref.as_str()),
8495            "merge must not null the survivor's content_ref"
8496        );
8497    }
8498
8499    #[tokio::test]
8500    async fn merge_entity_self_merge_rejected() {
8501        let rt = rt();
8502        let tok = NamespaceToken::local();
8503        let a = rt
8504            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8505            .await
8506            .unwrap();
8507        let err = rt
8508            .merge_entity_with_reason(
8509                &tok,
8510                a.id,
8511                a.id,
8512                EntityDedupMergePolicy::PreferInto,
8513                ContentMergeStrategy::Append,
8514                false,
8515                None,
8516            )
8517            .await
8518            .unwrap_err();
8519        assert!(
8520            format!("{err:?}").contains("cannot merge an entity into itself"),
8521            "expected self-merge rejection, got: {err:?}"
8522        );
8523    }
8524
8525    #[tokio::test]
8526    async fn merge_entity_prefer_into_strategy() {
8527        let rt = rt();
8528        let tok = NamespaceToken::local();
8529        let into = rt
8530            .create_entity(
8531                &tok,
8532                "concept",
8533                None,
8534                "Into",
8535                None,
8536                Some(serde_json::json!({"a": 1})),
8537                vec![],
8538            )
8539            .await
8540            .unwrap();
8541        let from = rt
8542            .create_entity(
8543                &tok,
8544                "concept",
8545                None,
8546                "From",
8547                None,
8548                Some(serde_json::json!({"a": 2, "b": 3})),
8549                vec![],
8550            )
8551            .await
8552            .unwrap();
8553
8554        rt.merge_entity_with_reason(
8555            &tok,
8556            into.id,
8557            from.id,
8558            EntityDedupMergePolicy::PreferInto,
8559            ContentMergeStrategy::Append,
8560            false,
8561            None,
8562        )
8563        .await
8564        .unwrap();
8565
8566        let kept = rt.get_entity(&tok, into.id).await.unwrap();
8567        let props = kept.properties.unwrap();
8568        // a stays as 1 (into wins), b is added from from.
8569        assert_eq!(props["a"], 1);
8570        assert_eq!(props["b"], 3);
8571    }
8572
8573    #[tokio::test]
8574    async fn merge_entity_prefer_from_strategy() {
8575        let rt = rt();
8576        let tok = NamespaceToken::local();
8577        let into = rt
8578            .create_entity(
8579                &tok,
8580                "concept",
8581                None,
8582                "Into",
8583                None,
8584                Some(serde_json::json!({"a": 1})),
8585                vec![],
8586            )
8587            .await
8588            .unwrap();
8589        let from = rt
8590            .create_entity(
8591                &tok,
8592                "concept",
8593                None,
8594                "From",
8595                None,
8596                Some(serde_json::json!({"a": 2, "b": 3})),
8597                vec![],
8598            )
8599            .await
8600            .unwrap();
8601
8602        rt.merge_entity_with_reason(
8603            &tok,
8604            into.id,
8605            from.id,
8606            EntityDedupMergePolicy::PreferFrom,
8607            ContentMergeStrategy::Append,
8608            false,
8609            None,
8610        )
8611        .await
8612        .unwrap();
8613
8614        let kept = rt.get_entity(&tok, into.id).await.unwrap();
8615        let props = kept.properties.unwrap();
8616        // from wins on a, b also from from.
8617        assert_eq!(props["a"], 2);
8618        assert_eq!(props["b"], 3);
8619    }
8620
8621    #[tokio::test]
8622    async fn merge_entity_union_strategy() {
8623        let rt = rt();
8624        let tok = NamespaceToken::local();
8625        let into = rt
8626            .create_entity(
8627                &tok,
8628                "concept",
8629                None,
8630                "Into",
8631                None,
8632                Some(serde_json::json!({"a": 1})),
8633                vec![],
8634            )
8635            .await
8636            .unwrap();
8637        let from = rt
8638            .create_entity(
8639                &tok,
8640                "concept",
8641                None,
8642                "From",
8643                None,
8644                Some(serde_json::json!({"a": 2, "b": 3})),
8645                vec![],
8646            )
8647            .await
8648            .unwrap();
8649
8650        rt.merge_entity_with_reason(
8651            &tok,
8652            into.id,
8653            from.id,
8654            EntityDedupMergePolicy::Union,
8655            ContentMergeStrategy::Append,
8656            false,
8657            None,
8658        )
8659        .await
8660        .unwrap();
8661
8662        let kept = rt.get_entity(&tok, into.id).await.unwrap();
8663        let props = kept.properties.unwrap();
8664        // Scalar conflict: into wins → a=1. b added from from.
8665        assert_eq!(props["a"], 1);
8666        assert_eq!(props["b"], 3);
8667    }
8668
8669    #[tokio::test]
8670    async fn merge_entity_unions_tags() {
8671        let rt = rt();
8672        let tok = NamespaceToken::local();
8673        let into = rt
8674            .create_entity(
8675                &tok,
8676                "concept",
8677                None,
8678                "Into",
8679                None,
8680                None,
8681                vec!["x".to_string(), "y".to_string()],
8682            )
8683            .await
8684            .unwrap();
8685        let from = rt
8686            .create_entity(
8687                &tok,
8688                "concept",
8689                None,
8690                "From",
8691                None,
8692                None,
8693                vec!["y".to_string(), "z".to_string()],
8694            )
8695            .await
8696            .unwrap();
8697
8698        rt.merge_entity_with_reason(
8699            &tok,
8700            into.id,
8701            from.id,
8702            EntityDedupMergePolicy::PreferInto,
8703            ContentMergeStrategy::Append,
8704            false,
8705            None,
8706        )
8707        .await
8708        .unwrap();
8709
8710        let kept = rt.get_entity(&tok, into.id).await.unwrap();
8711        let mut tags = kept.tags.clone();
8712        tags.sort();
8713        assert_eq!(tags, vec!["x", "y", "z"]);
8714    }
8715
8716    /// An event-store failure must roll back the edge deletion and tombstone.
8717    /// Before the transactional event insert, this left a committed merge with
8718    /// no durable preimage, even though the call returned an error.
8719    #[tokio::test]
8720    async fn entity_merge_event_insert_failure_rolls_back_destructive_merge() {
8721        let rt = rt();
8722        let tok = NamespaceToken::local();
8723        let into = rt
8724            .create_entity(&tok, "concept", None, "Into", None, None, vec![])
8725            .await
8726            .unwrap();
8727        let from = rt
8728            .create_entity(&tok, "concept", None, "From", None, None, vec![])
8729            .await
8730            .unwrap();
8731        let edge = rt
8732            .link(&tok, into.id, from.id, EdgeRelation::Extends, 1.0, None)
8733            .await
8734            .unwrap();
8735        let event_store = rt.events(&tok).unwrap();
8736        set_merge_event_refusal(&rt, "entity_merged", true);
8737
8738        let failed = rt
8739            .merge_entity(
8740                &tok,
8741                into.id,
8742                from.id,
8743                EntityDedupMergePolicy::PreferInto,
8744                ContentMergeStrategy::Append,
8745                false,
8746            )
8747            .await;
8748        assert!(failed.is_err(), "event insert must abort the merge");
8749        let source = rt.get_entity(&tok, from.id).await.unwrap();
8750        assert_eq!(source.merge_event_id, None);
8751        assert!(source.deleted_at.is_none());
8752        assert!(
8753            rt.get_edge_including_deleted(&tok, edge.id.into())
8754                .await
8755                .unwrap()
8756                .is_some(),
8757            "the deleted self-loop must roll back"
8758        );
8759        let filter = khive_storage::EventFilter {
8760            kinds: vec![EventKind::EntityMerged],
8761            ..Default::default()
8762        };
8763        let page = khive_storage::types::PageRequest {
8764            offset: 0,
8765            limit: 10,
8766        };
8767        assert!(event_store
8768            .query_events(filter.clone(), page.clone())
8769            .await
8770            .unwrap()
8771            .items
8772            .is_empty());
8773
8774        set_merge_event_refusal(&rt, "entity_merged", false);
8775        let summary = rt
8776            .merge_entity(
8777                &tok,
8778                into.id,
8779                from.id,
8780                EntityDedupMergePolicy::PreferInto,
8781                ContentMergeStrategy::Append,
8782                false,
8783            )
8784            .await
8785            .unwrap();
8786        let tombstone = rt
8787            .entities(&tok)
8788            .unwrap()
8789            .get_entity_including_deleted(from.id)
8790            .await
8791            .unwrap()
8792            .unwrap();
8793        let events = event_store.query_events(filter, page).await.unwrap();
8794        assert_eq!(events.items.len(), 1);
8795        assert_eq!(tombstone.merge_event_id, Some(events.items[0].id));
8796        assert_eq!(
8797            events.items[0].payload["self_loop_edge_preimages"],
8798            serde_json::to_value(&summary.self_loop_edge_preimages).unwrap()
8799        );
8800    }
8801
8802    #[tokio::test]
8803    async fn merge_entity_drops_self_loops() {
8804        let rt = rt();
8805        let tok = NamespaceToken::local();
8806        let a = rt
8807            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8808            .await
8809            .unwrap();
8810        let b = rt
8811            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8812            .await
8813            .unwrap();
8814
8815        // A `extends` B — merging B into A would produce A `extends` A → drop it.
8816        let edge = rt
8817            .link(
8818                &tok,
8819                a.id,
8820                b.id,
8821                EdgeRelation::Extends,
8822                0.6,
8823                Some(serde_json::json!({"basis": "shared lineage"})),
8824            )
8825            .await
8826            .unwrap();
8827
8828        let summary = rt
8829            .merge_entity_with_reason(
8830                &tok,
8831                a.id,
8832                b.id,
8833                EntityDedupMergePolicy::PreferInto,
8834                ContentMergeStrategy::Append,
8835                false,
8836                None,
8837            )
8838            .await
8839            .unwrap();
8840
8841        assert_eq!(
8842            summary.edges_rewired, 0,
8843            "self-loop should be dropped, not rewired"
8844        );
8845
8846        // The dropped self-loop must be counted and its full preimage
8847        // captured (khive#2934) — before this fix the edge vanished with
8848        // neither the counter nor a recoverable row.
8849        assert_eq!(summary.edges_self_loop_dropped, 1);
8850        let [preimage] = summary.self_loop_edge_preimages.as_slice() else {
8851            panic!(
8852                "expected exactly one self-loop preimage, got {:?}",
8853                summary.self_loop_edge_preimages
8854            );
8855        };
8856        assert_eq!(preimage.id, Uuid::from(edge.id));
8857        assert_eq!(preimage.source_id, a.id);
8858        assert_eq!(preimage.target_id, b.id);
8859        assert_eq!(preimage.relation, "extends");
8860        assert_eq!(preimage.weight, 0.6);
8861        assert_eq!(
8862            preimage.metadata,
8863            Some(serde_json::json!({"basis": "shared lineage"}))
8864        );
8865
8866        let a_out = rt
8867            .neighbors(&tok, a.id, Direction::Out, None, None)
8868            .await
8869            .unwrap();
8870        assert!(a_out.is_empty(), "no self-loop should remain");
8871
8872        let events = rt
8873            .events(&tok)
8874            .unwrap()
8875            .query_events(
8876                khive_storage::EventFilter {
8877                    kinds: vec![EventKind::EntityMerged],
8878                    ..Default::default()
8879                },
8880                khive_storage::types::PageRequest {
8881                    offset: 0,
8882                    limit: 10,
8883                },
8884            )
8885            .await
8886            .unwrap();
8887        assert_eq!(events.items.len(), 1);
8888        assert_eq!(
8889            events.items[0].payload["edges_self_loop_dropped"],
8890            serde_json::json!(1)
8891        );
8892        assert_eq!(
8893            events.items[0].payload["self_loop_edge_preimages"],
8894            serde_json::to_value(&summary.self_loop_edge_preimages).unwrap()
8895        );
8896    }
8897
8898    // A dry run must predict the exact self-loop-drop count and preimage a
8899    // committed merge produces — before khive#2934 the `continue` in the
8900    // self-loop branch ran before both the write gate and any counter, so a
8901    // dry run and a real run were indistinguishable for this case.
8902    #[tokio::test]
8903    async fn merge_entity_self_loop_dry_run_matches_real_run() {
8904        let rt = rt();
8905        let tok = NamespaceToken::local();
8906        let into = rt
8907            .create_entity(&tok, "concept", None, "Into", None, None, vec![])
8908            .await
8909            .unwrap();
8910        let from = rt
8911            .create_entity(&tok, "concept", None, "From", None, None, vec![])
8912            .await
8913            .unwrap();
8914        let edge = rt
8915            .link(
8916                &tok,
8917                into.id,
8918                from.id,
8919                EdgeRelation::Extends,
8920                0.5,
8921                Some(serde_json::json!({"basis": "dry-run parity"})),
8922            )
8923            .await
8924            .unwrap();
8925
8926        let dry_summary = rt
8927            .merge_entity(
8928                &tok,
8929                into.id,
8930                from.id,
8931                EntityDedupMergePolicy::PreferInto,
8932                ContentMergeStrategy::Append,
8933                true,
8934            )
8935            .await
8936            .unwrap();
8937
8938        assert!(
8939            rt.get_edge_including_deleted(&tok, edge.id.into())
8940                .await
8941                .unwrap()
8942                .is_some(),
8943            "dry run must not delete the self-loop edge"
8944        );
8945
8946        let real_summary = rt
8947            .merge_entity(
8948                &tok,
8949                into.id,
8950                from.id,
8951                EntityDedupMergePolicy::PreferInto,
8952                ContentMergeStrategy::Append,
8953                false,
8954            )
8955            .await
8956            .unwrap();
8957
8958        assert_eq!(dry_summary.edges_self_loop_dropped, 1);
8959        let [dry_preimage] = dry_summary.self_loop_edge_preimages.as_slice() else {
8960            panic!(
8961                "expected exactly one predicted self-loop preimage, got {:?}",
8962                dry_summary.self_loop_edge_preimages
8963            );
8964        };
8965        assert_eq!(dry_preimage.id, Uuid::from(edge.id));
8966        assert_eq!(dry_preimage.source_id, into.id);
8967        assert_eq!(dry_preimage.target_id, from.id);
8968        assert_eq!(dry_preimage.relation, "extends");
8969        assert_eq!(dry_preimage.weight, 0.5);
8970        assert_eq!(
8971            dry_summary.edges_self_loop_dropped, real_summary.edges_self_loop_dropped,
8972            "a dry run must predict the same self-loop-drop count the committed merge produces"
8973        );
8974        assert_eq!(
8975            dry_summary.self_loop_edge_preimages, real_summary.self_loop_edge_preimages,
8976            "a dry run must predict the exact preimage the committed merge produces"
8977        );
8978
8979        assert!(
8980            rt.get_edge_including_deleted(&tok, edge.id.into())
8981                .await
8982                .unwrap()
8983                .is_none(),
8984            "the committed merge must actually delete the self-loop edge"
8985        );
8986    }
8987
8988    // Control: no edge exists directly between the merge operands, only one
8989    // that survives the rewire — the self-loop counter must stay at zero
8990    // rather than firing on every rewired edge.
8991    #[tokio::test]
8992    async fn merge_entity_no_self_loop_between_operands_reports_zero() {
8993        let rt = rt();
8994        let tok = NamespaceToken::local();
8995        let into = rt
8996            .create_entity(&tok, "concept", None, "Into", None, None, vec![])
8997            .await
8998            .unwrap();
8999        let from = rt
9000            .create_entity(&tok, "concept", None, "From", None, None, vec![])
9001            .await
9002            .unwrap();
9003        let other = rt
9004            .create_entity(&tok, "concept", None, "Other", None, None, vec![])
9005            .await
9006            .unwrap();
9007
9008        rt.link(&tok, from.id, other.id, EdgeRelation::Extends, 1.0, None)
9009            .await
9010            .unwrap();
9011
9012        let summary = rt
9013            .merge_entity(
9014                &tok,
9015                into.id,
9016                from.id,
9017                EntityDedupMergePolicy::PreferInto,
9018                ContentMergeStrategy::Append,
9019                false,
9020            )
9021            .await
9022            .unwrap();
9023
9024        assert_eq!(
9025            summary.edges_rewired, 1,
9026            "the non-self-loop edge must still rewire"
9027        );
9028        assert_eq!(
9029            summary.edges_self_loop_dropped, 0,
9030            "no self-loop exists between the merge operands"
9031        );
9032        assert!(summary.self_loop_edge_preimages.is_empty());
9033    }
9034
9035    // ---- content_strategy for entity merge ----
9036
9037    #[tokio::test]
9038    async fn merge_entity_append_strategy_concatenates_descriptions() {
9039        let rt = rt();
9040        let tok = NamespaceToken::local();
9041        let into = rt
9042            .create_entity(&tok, "concept", None, "Into", Some("desc A"), None, vec![])
9043            .await
9044            .unwrap();
9045        let from = rt
9046            .create_entity(&tok, "concept", None, "From", Some("desc B"), None, vec![])
9047            .await
9048            .unwrap();
9049
9050        let summary = rt
9051            .merge_entity_with_reason(
9052                &tok,
9053                into.id,
9054                from.id,
9055                EntityDedupMergePolicy::PreferInto,
9056                ContentMergeStrategy::Append,
9057                false,
9058                None,
9059            )
9060            .await
9061            .unwrap();
9062
9063        assert!(
9064            summary.content_appended,
9065            "append strategy with two non-empty descriptions must report content_appended=true"
9066        );
9067        let kept = rt.get_entity(&tok, into.id).await.unwrap();
9068        assert_eq!(kept.description.as_deref(), Some("desc A\n\n---\n\ndesc B"));
9069    }
9070
9071    #[tokio::test]
9072    async fn merge_entity_append_strategy_from_empty_is_noop() {
9073        let rt = rt();
9074        let tok = NamespaceToken::local();
9075        let into = rt
9076            .create_entity(&tok, "concept", None, "Into", Some("desc A"), None, vec![])
9077            .await
9078            .unwrap();
9079        let from = rt
9080            .create_entity(&tok, "concept", None, "From", None, None, vec![])
9081            .await
9082            .unwrap();
9083
9084        let summary = rt
9085            .merge_entity_with_reason(
9086                &tok,
9087                into.id,
9088                from.id,
9089                EntityDedupMergePolicy::PreferInto,
9090                ContentMergeStrategy::Append,
9091                false,
9092                None,
9093            )
9094            .await
9095            .unwrap();
9096
9097        assert!(
9098            !summary.content_appended,
9099            "from's empty description means nothing was appended"
9100        );
9101        let kept = rt.get_entity(&tok, into.id).await.unwrap();
9102        assert_eq!(kept.description.as_deref(), Some("desc A"));
9103    }
9104
9105    #[tokio::test]
9106    async fn merge_entity_append_strategy_into_empty_takes_from() {
9107        let rt = rt();
9108        let tok = NamespaceToken::local();
9109        let into = rt
9110            .create_entity(&tok, "concept", None, "Into", None, None, vec![])
9111            .await
9112            .unwrap();
9113        let from = rt
9114            .create_entity(&tok, "concept", None, "From", Some("desc B"), None, vec![])
9115            .await
9116            .unwrap();
9117
9118        let summary = rt
9119            .merge_entity_with_reason(
9120                &tok,
9121                into.id,
9122                from.id,
9123                EntityDedupMergePolicy::PreferInto,
9124                ContentMergeStrategy::Append,
9125                false,
9126                None,
9127            )
9128            .await
9129            .unwrap();
9130
9131        assert!(
9132            summary.content_appended,
9133            "taking from's description into an empty into is real content preservation"
9134        );
9135        let kept = rt.get_entity(&tok, into.id).await.unwrap();
9136        assert_eq!(kept.description.as_deref(), Some("desc B"));
9137    }
9138
9139    #[tokio::test]
9140    async fn merge_entity_prefer_into_strategy_still_discards_explicitly() {
9141        let rt = rt();
9142        let tok = NamespaceToken::local();
9143        let into = rt
9144            .create_entity(&tok, "concept", None, "Into", Some("desc A"), None, vec![])
9145            .await
9146            .unwrap();
9147        let from = rt
9148            .create_entity(&tok, "concept", None, "From", Some("desc B"), None, vec![])
9149            .await
9150            .unwrap();
9151
9152        let summary = rt
9153            .merge_entity_with_reason(
9154                &tok,
9155                into.id,
9156                from.id,
9157                EntityDedupMergePolicy::PreferInto,
9158                ContentMergeStrategy::PreferInto,
9159                false,
9160                None,
9161            )
9162            .await
9163            .unwrap();
9164
9165        assert!(
9166            !summary.content_appended,
9167            "explicit PreferInto opt-out must not report an append"
9168        );
9169        let kept = rt.get_entity(&tok, into.id).await.unwrap();
9170        assert_eq!(
9171            kept.description.as_deref(),
9172            Some("desc A"),
9173            "explicit PreferInto opt-out keeps the old discard behavior"
9174        );
9175    }
9176
9177    /// `content_strategy` must be followed directly, independent of the
9178    /// entity-field `strategy`: with the default entity policy `prefer_into`,
9179    /// an explicit `content_strategy=prefer_from` must still keep the
9180    /// from-description.
9181    #[tokio::test]
9182    async fn merge_entity_prefer_from_content_strategy_wins_over_default_entity_policy() {
9183        let rt = rt();
9184        let tok = NamespaceToken::local();
9185        let into = rt
9186            .create_entity(&tok, "concept", None, "Into", Some("desc A"), None, vec![])
9187            .await
9188            .unwrap();
9189        let from = rt
9190            .create_entity(&tok, "concept", None, "From", Some("desc B"), None, vec![])
9191            .await
9192            .unwrap();
9193
9194        let summary = rt
9195            .merge_entity_with_reason(
9196                &tok,
9197                into.id,
9198                from.id,
9199                EntityDedupMergePolicy::PreferInto,
9200                ContentMergeStrategy::PreferFrom,
9201                false,
9202                None,
9203            )
9204            .await
9205            .unwrap();
9206
9207        assert!(
9208            !summary.content_appended,
9209            "explicit PreferFrom is not an append"
9210        );
9211        let kept = rt.get_entity(&tok, into.id).await.unwrap();
9212        assert_eq!(
9213            kept.description.as_deref(),
9214            Some("desc B"),
9215            "content_strategy=prefer_from must win over the default prefer_into entity policy"
9216        );
9217    }
9218
9219    #[tokio::test]
9220    async fn merge_entity_dry_run_previews_append() {
9221        let rt = rt();
9222        let tok = NamespaceToken::local();
9223        let into = rt
9224            .create_entity(&tok, "concept", None, "Into", Some("desc A"), None, vec![])
9225            .await
9226            .unwrap();
9227        let from = rt
9228            .create_entity(&tok, "concept", None, "From", Some("desc B"), None, vec![])
9229            .await
9230            .unwrap();
9231
9232        let summary = rt
9233            .merge_entity_with_reason(
9234                &tok,
9235                into.id,
9236                from.id,
9237                EntityDedupMergePolicy::PreferInto,
9238                ContentMergeStrategy::Append,
9239                true,
9240                None,
9241            )
9242            .await
9243            .unwrap();
9244
9245        assert!(summary.dry_run);
9246        assert!(
9247            summary.content_appended,
9248            "dry-run must preview the append outcome without writing"
9249        );
9250        let kept = rt.get_entity(&tok, into.id).await.unwrap();
9251        assert_eq!(
9252            kept.description.as_deref(),
9253            Some("desc A"),
9254            "dry_run=true must not mutate the into entity's description"
9255        );
9256    }
9257
9258    /// Dry-run must be a read-only, accurate preview: it must predict
9259    /// `edges_rewired` without writing, and must not append an `EntityMerged` event.
9260    #[tokio::test]
9261    async fn merge_entity_dry_run_predicts_edges_rewired_without_writing() {
9262        use khive_storage::EdgeRelation;
9263
9264        let rt = rt();
9265        let tok = NamespaceToken::local();
9266        let a = rt
9267            .create_entity(&tok, "concept", None, "A", None, None, vec![])
9268            .await
9269            .unwrap();
9270        let into = rt
9271            .create_entity(&tok, "concept", None, "Into", None, None, vec![])
9272            .await
9273            .unwrap();
9274        let from = rt
9275            .create_entity(&tok, "concept", None, "From", None, None, vec![])
9276            .await
9277            .unwrap();
9278
9279        rt.link(&tok, a.id, from.id, EdgeRelation::Extends, 1.0, None)
9280            .await
9281            .unwrap();
9282
9283        let summary = rt
9284            .merge_entity_with_reason(
9285                &tok,
9286                into.id,
9287                from.id,
9288                EntityDedupMergePolicy::PreferInto,
9289                ContentMergeStrategy::Append,
9290                true,
9291                None,
9292            )
9293            .await
9294            .unwrap();
9295
9296        assert!(summary.dry_run);
9297        assert_eq!(
9298            summary.edges_rewired, 1,
9299            "dry-run must predict the edge that would be rewired, not report zero"
9300        );
9301
9302        let a_neighbors = rt
9303            .neighbors(&tok, a.id, Direction::Out, None, None)
9304            .await
9305            .unwrap();
9306        assert_eq!(a_neighbors.len(), 1);
9307        assert_eq!(
9308            a_neighbors[0].node_id, from.id,
9309            "dry_run=true must not rewire any edges"
9310        );
9311
9312        let events = rt
9313            .events(&tok)
9314            .unwrap()
9315            .query_events(
9316                khive_storage::EventFilter {
9317                    kinds: vec![EventKind::EntityMerged],
9318                    ..Default::default()
9319                },
9320                khive_storage::types::PageRequest {
9321                    offset: 0,
9322                    limit: 10,
9323                },
9324            )
9325            .await
9326            .unwrap();
9327        assert!(
9328            events.items.is_empty(),
9329            "dry_run=true must not append an EntityMerged event"
9330        );
9331    }
9332
9333    /// ADR-014: `reason` is additive — when supplied it must land in the
9334    /// `EntityMerged` payload verbatim; the key must be entirely absent (not
9335    /// `null`) when the caller omits it.
9336    #[tokio::test]
9337    async fn merge_entity_event_reason_present_when_supplied_absent_when_not() {
9338        let rt = rt();
9339        let tok = NamespaceToken::local();
9340
9341        let into_a = rt
9342            .create_entity(&tok, "concept", None, "IntoA", None, None, vec![])
9343            .await
9344            .unwrap();
9345        let from_a = rt
9346            .create_entity(&tok, "concept", None, "FromA", None, None, vec![])
9347            .await
9348            .unwrap();
9349        rt.merge_entity_with_reason(
9350            &tok,
9351            into_a.id,
9352            from_a.id,
9353            EntityDedupMergePolicy::PreferInto,
9354            ContentMergeStrategy::Append,
9355            false,
9356            Some("duplicate".to_string()),
9357        )
9358        .await
9359        .unwrap();
9360
9361        let into_b = rt
9362            .create_entity(&tok, "concept", None, "IntoB", None, None, vec![])
9363            .await
9364            .unwrap();
9365        let from_b = rt
9366            .create_entity(&tok, "concept", None, "FromB", None, None, vec![])
9367            .await
9368            .unwrap();
9369        rt.merge_entity_with_reason(
9370            &tok,
9371            into_b.id,
9372            from_b.id,
9373            EntityDedupMergePolicy::PreferInto,
9374            ContentMergeStrategy::Append,
9375            false,
9376            None,
9377        )
9378        .await
9379        .unwrap();
9380
9381        let events = rt
9382            .events(&tok)
9383            .unwrap()
9384            .query_events(
9385                khive_storage::EventFilter {
9386                    kinds: vec![EventKind::EntityMerged],
9387                    ..Default::default()
9388                },
9389                khive_storage::types::PageRequest {
9390                    offset: 0,
9391                    limit: 10,
9392                },
9393            )
9394            .await
9395            .unwrap();
9396        assert_eq!(events.items.len(), 2);
9397
9398        let with_reason = events
9399            .items
9400            .iter()
9401            .find(|e| {
9402                e.payload.get("from_id").and_then(|v| v.as_str())
9403                    == Some(from_a.id.to_string()).as_deref()
9404            })
9405            .expect("event for the reasoned merge must exist");
9406        assert_eq!(
9407            with_reason.payload.get("reason").and_then(|v| v.as_str()),
9408            Some("duplicate"),
9409            "reason must be threaded verbatim into the payload when supplied"
9410        );
9411
9412        let without_reason = events
9413            .items
9414            .iter()
9415            .find(|e| {
9416                e.payload.get("from_id").and_then(|v| v.as_str())
9417                    == Some(from_b.id.to_string()).as_deref()
9418            })
9419            .expect("event for the reasonless merge must exist");
9420        assert!(
9421            without_reason.payload.get("reason").is_none(),
9422            "reason key must be absent (never null) when the caller omits it, got: {:?}",
9423            without_reason.payload
9424        );
9425    }
9426
9427    /// ADR-018 Amendment 5 says the forced-merge trail names the acting actor.
9428    /// The emission site passes an empty actor string, so reading it alone says
9429    /// the opposite; `KhiveRuntime::events` wraps the store in the attribution
9430    /// decorator, which replaces namespace and actor from the authorized token
9431    /// on every append. This pins the PERSISTED value, and the second arm makes
9432    /// it a reading of the token rather than of a constant.
9433    #[tokio::test]
9434    async fn a_forced_merge_event_names_the_acting_actor_not_an_empty_string() {
9435        async fn forced_merge_event_actor(actor_id: Option<&str>) -> (String, serde_json::Value) {
9436            let rt = KhiveRuntime::new(crate::RuntimeConfig {
9437                db_path: None,
9438                packs: vec!["kg".to_string()],
9439                brain_profile: None,
9440                actor_id: actor_id.map(str::to_string),
9441                ..crate::RuntimeConfig::no_embeddings()
9442            })
9443            .expect("runtime");
9444            let tok = rt.authorize(crate::Namespace::local()).expect("authorize");
9445            let into = rt
9446                .create_entity(&tok, "concept", None, "Flash Attention", None, None, vec![])
9447                .await
9448                .unwrap();
9449            let from = rt
9450                .create_entity(&tok, "concept", None, "Paged KV Cache", None, None, vec![])
9451                .await
9452                .unwrap();
9453            rt.merge_entity_with_reason_and_force(
9454                &tok,
9455                into.id,
9456                from.id,
9457                EntityDedupMergePolicy::PreferInto,
9458                ContentMergeStrategy::Append,
9459                false,
9460                None,
9461                true,
9462            )
9463            .await
9464            .expect("the floor refuses this pair, so only force lands it");
9465            let events = rt
9466                .events(&tok)
9467                .unwrap()
9468                .query_events(
9469                    khive_storage::EventFilter {
9470                        kinds: vec![EventKind::EntityMerged],
9471                        ..Default::default()
9472                    },
9473                    khive_storage::types::PageRequest {
9474                        offset: 0,
9475                        limit: 10,
9476                    },
9477                )
9478                .await
9479                .unwrap();
9480            assert_eq!(events.items.len(), 1, "one forced merge, one event");
9481            let event = &events.items[0];
9482            (event.actor.clone(), event.payload.clone())
9483        }
9484
9485        let (actor, payload) = forced_merge_event_actor(Some("merge-forcer")).await;
9486        assert_eq!(
9487            actor, "actor:merge-forcer",
9488            "the persisted event must name the actor the token carries"
9489        );
9490        assert_eq!(
9491            payload.get("force"),
9492            Some(&serde_json::Value::Bool(true)),
9493            "the force marker rides the same event: {payload}"
9494        );
9495
9496        let (anonymous, _) = forced_merge_event_actor(None).await;
9497        assert_eq!(
9498            anonymous, "anonymous:local",
9499            "an unconfigured runtime stamps the anonymous fallback, so the field \
9500             tracks the token rather than a constant"
9501        );
9502    }
9503
9504    #[tokio::test]
9505    async fn merge_entity_with_reason_preserves_an_explicit_empty_reason() {
9506        let rt = rt();
9507        let tok = NamespaceToken::local();
9508        let into = rt
9509            .create_entity(&tok, "concept", None, "Into", None, None, vec![])
9510            .await
9511            .unwrap();
9512        let from = rt
9513            .create_entity(&tok, "concept", None, "From", None, None, vec![])
9514            .await
9515            .unwrap();
9516
9517        rt.merge_entity_with_reason(
9518            &tok,
9519            into.id,
9520            from.id,
9521            EntityDedupMergePolicy::PreferInto,
9522            ContentMergeStrategy::Append,
9523            false,
9524            Some(String::new()),
9525        )
9526        .await
9527        .unwrap();
9528
9529        let events = rt
9530            .events(&tok)
9531            .unwrap()
9532            .query_events(
9533                khive_storage::EventFilter {
9534                    kinds: vec![EventKind::EntityMerged],
9535                    ..Default::default()
9536                },
9537                khive_storage::types::PageRequest {
9538                    offset: 0,
9539                    limit: 10,
9540                },
9541            )
9542            .await
9543            .unwrap();
9544        assert_eq!(events.items.len(), 1);
9545        assert_eq!(
9546            events.items[0].payload.get("reason"),
9547            Some(&Value::String(String::new()))
9548        );
9549    }
9550
9551    #[tokio::test]
9552    async fn merge_entity_with_reason_rejects_secrets_before_reads_or_writes() {
9553        let rt = rt();
9554        let tok = NamespaceToken::local();
9555        let into = rt
9556            .create_entity(&tok, "concept", None, "Into", None, None, vec![])
9557            .await
9558            .unwrap();
9559        let from = rt
9560            .create_entity(&tok, "concept", None, "From", None, None, vec![])
9561            .await
9562            .unwrap();
9563        let secret = secret_shaped_reason();
9564
9565        let error = rt
9566            .merge_entity_with_reason(
9567                &tok,
9568                into.id,
9569                from.id,
9570                EntityDedupMergePolicy::PreferInto,
9571                ContentMergeStrategy::Append,
9572                false,
9573                Some(secret),
9574            )
9575            .await
9576            .unwrap_err();
9577
9578        assert!(matches!(error, RuntimeError::SecretDetected(_)));
9579        assert_eq!(rt.get_entity(&tok, into.id).await.unwrap().id, into.id);
9580        assert_eq!(rt.get_entity(&tok, from.id).await.unwrap().id, from.id);
9581        let event_count = rt
9582            .events(&tok)
9583            .unwrap()
9584            .count_events(khive_storage::EventFilter {
9585                kinds: vec![EventKind::EntityMerged],
9586                ..Default::default()
9587            })
9588            .await
9589            .unwrap();
9590        assert_eq!(event_count, 0);
9591    }
9592
9593    /// ADR-014: `merge_note` must be as auditable as `merge_entity` — exactly one
9594    /// `NoteMerged` event, carrying kept/absorbed ids, per note merge.
9595    #[tokio::test]
9596    async fn merge_note_emits_exactly_one_note_merged_event_with_kept_and_absorbed_ids() {
9597        let rt = rt();
9598        let tok = NamespaceToken::local();
9599        let into = rt
9600            .create_note(&tok, "observation", None, "into note", None, None, vec![])
9601            .await
9602            .unwrap();
9603        let from = rt
9604            .create_note(&tok, "observation", None, "from note", None, None, vec![])
9605            .await
9606            .unwrap();
9607
9608        let summary = rt
9609            .merge_note_with_reason(
9610                &tok,
9611                into.id,
9612                from.id,
9613                EntityDedupMergePolicy::PreferInto,
9614                ContentMergeStrategy::Append,
9615                false,
9616                Some("duplicate".to_string()),
9617            )
9618            .await
9619            .unwrap();
9620
9621        let events = rt
9622            .events(&tok)
9623            .unwrap()
9624            .query_events(
9625                khive_storage::EventFilter {
9626                    kinds: vec![EventKind::NoteMerged],
9627                    ..Default::default()
9628                },
9629                khive_storage::types::PageRequest {
9630                    offset: 0,
9631                    limit: 10,
9632                },
9633            )
9634            .await
9635            .unwrap();
9636        assert_eq!(
9637            events.items.len(),
9638            1,
9639            "merge_note must emit exactly one NoteMerged event"
9640        );
9641
9642        let payload = &events.items[0].payload;
9643        assert_eq!(
9644            payload.get("into_id").and_then(|v| v.as_str()),
9645            Some(summary.kept_id.to_string()).as_deref()
9646        );
9647        assert_eq!(
9648            payload.get("from_id").and_then(|v| v.as_str()),
9649            Some(summary.removed_id.to_string()).as_deref()
9650        );
9651        assert_eq!(
9652            payload.get("reason").and_then(|v| v.as_str()),
9653            Some("duplicate")
9654        );
9655    }
9656
9657    #[tokio::test]
9658    async fn merge_note_returns_committed_summary_when_post_commit_reindex_fails() {
9659        use crate::operations::arm_fts_fail_scoped;
9660
9661        const DIMS: usize = 4;
9662        let rt = rt();
9663        rt.register_embedder(MergeTestVecProvider::new(
9664            "merge-note-reindex-failure",
9665            DIMS,
9666        ));
9667        let namespace = format!("merge-reindex-failure-{}", Uuid::new_v4().as_simple());
9668        let tok = NamespaceToken::for_namespace(crate::Namespace::parse(&namespace).unwrap());
9669        let into = rt
9670            .create_note(
9671                &tok,
9672                "observation",
9673                None,
9674                "survivor content",
9675                None,
9676                Some(serde_json::json!({"survivor": "retained"})),
9677                vec![],
9678            )
9679            .await
9680            .expect("create survivor note");
9681        let from = rt
9682            .create_note(
9683                &tok,
9684                "observation",
9685                None,
9686                "source content",
9687                None,
9688                Some(serde_json::json!({"merged": "source"})),
9689                vec![],
9690            )
9691            .await
9692            .expect("create source note");
9693
9694        let observed_by_hook = Arc::new(Mutex::new(Vec::new()));
9695        let hook_observations = Arc::clone(&observed_by_hook);
9696        let event_store = rt.events(&tok).expect("event store");
9697        rt.install_note_mutation_hook(Arc::new(move |kind, id| {
9698            let hook_observations = Arc::clone(&hook_observations);
9699            let event_store = Arc::clone(&event_store);
9700            Box::pin(async move {
9701                let events = event_store
9702                    .query_events(
9703                        khive_storage::EventFilter {
9704                            kinds: vec![EventKind::NoteMerged],
9705                            ..Default::default()
9706                        },
9707                        khive_storage::types::PageRequest {
9708                            offset: 0,
9709                            limit: 10,
9710                        },
9711                    )
9712                    .await
9713                    .expect("read merge event from mutation hook");
9714                hook_observations
9715                    .lock()
9716                    .unwrap()
9717                    .push((kind, id, !events.items.is_empty()));
9718            })
9719        }));
9720
9721        let _arm = arm_fts_fail_scoped(&namespace);
9722        let outcome = rt
9723            .merge_note(
9724                &tok,
9725                into.id,
9726                from.id,
9727                EntityDedupMergePolicy::Union,
9728                ContentMergeStrategy::Append,
9729                false,
9730            )
9731            .await;
9732        assert!(
9733            outcome.is_ok(),
9734            "a committed merge must return its summary after reindexing fails: {outcome:?}"
9735        );
9736        let summary = outcome.expect("the committed merge summary must be returned");
9737        assert_eq!(summary.kept_id, into.id);
9738        assert_eq!(summary.removed_id, from.id);
9739        assert!(
9740            summary
9741                .post_commit_reindex_error
9742                .as_deref()
9743                .is_some_and(|error| error.contains("injected FTS failure")),
9744            "the summary must report the post-commit reindex failure: {:?}",
9745            summary.post_commit_reindex_error
9746        );
9747
9748        assert_eq!(
9749            observed_by_hook.lock().unwrap().as_slice(),
9750            &[("observation".to_string(), into.id, true)],
9751            "the note mutation hook must observe the committed merge event"
9752        );
9753
9754        let note_store = rt.notes(&tok).expect("note store");
9755        let survivor = note_store
9756            .get_note(into.id)
9757            .await
9758            .expect("read survivor")
9759            .expect("survivor remains live");
9760        assert_eq!(
9761            survivor.content,
9762            "survivor content\n\n---\n\nsource content"
9763        );
9764        let properties = survivor.properties.expect("merged properties");
9765        assert_eq!(properties["survivor"], "retained");
9766        assert_eq!(properties["merged"], "source");
9767
9768        let removed = note_store
9769            .get_note_including_deleted(from.id)
9770            .await
9771            .expect("read merge tombstone")
9772            .expect("source row is retained as a tombstone");
9773        assert_eq!(removed.status, "deleted");
9774        assert!(removed.deleted_at.is_some());
9775
9776        let events = rt
9777            .events(&tok)
9778            .expect("event store")
9779            .query_events(
9780                khive_storage::EventFilter {
9781                    kinds: vec![EventKind::NoteMerged],
9782                    ..Default::default()
9783                },
9784                khive_storage::types::PageRequest {
9785                    offset: 0,
9786                    limit: 10,
9787                },
9788            )
9789            .await
9790            .expect("read merge event");
9791        assert_eq!(
9792            events.items.len(),
9793            1,
9794            "the merge event must be recorded when reindexing fails"
9795        );
9796        assert_eq!(
9797            events.items[0]
9798                .payload
9799                .get("into_id")
9800                .and_then(|v| v.as_str()),
9801            Some(summary.kept_id.to_string()).as_deref()
9802        );
9803        assert_eq!(
9804            events.items[0]
9805                .payload
9806                .get("from_id")
9807                .and_then(|v| v.as_str()),
9808            Some(summary.removed_id.to_string()).as_deref()
9809        );
9810    }
9811
9812    #[tokio::test]
9813    async fn merge_note_fires_mutation_hook_without_embedding_models() {
9814        let rt = rt();
9815        let tok = NamespaceToken::local();
9816        let into = rt
9817            .create_note(&tok, "observation", None, "survivor", None, None, vec![])
9818            .await
9819            .expect("create survivor note");
9820        let from = rt
9821            .create_note(&tok, "observation", None, "source", None, None, vec![])
9822            .await
9823            .expect("create source note");
9824        let hook_calls = Arc::new(Mutex::new(Vec::new()));
9825        let observed_calls = Arc::clone(&hook_calls);
9826        rt.install_note_mutation_hook(Arc::new(move |kind, id| {
9827            let observed_calls = Arc::clone(&observed_calls);
9828            Box::pin(async move { observed_calls.lock().unwrap().push((kind, id)) })
9829        }));
9830
9831        rt.merge_note(
9832            &tok,
9833            into.id,
9834            from.id,
9835            EntityDedupMergePolicy::PreferInto,
9836            ContentMergeStrategy::Append,
9837            false,
9838        )
9839        .await
9840        .expect("merge note without registered embedding models");
9841
9842        assert_eq!(
9843            hook_calls.lock().unwrap().as_slice(),
9844            &[("observation".to_string(), into.id)],
9845            "every committed note merge must notify mutation hooks without embedding models"
9846        );
9847    }
9848
9849    #[tokio::test]
9850    async fn merge_note_with_reason_preserves_an_explicit_empty_reason() {
9851        let rt = rt();
9852        let tok = NamespaceToken::local();
9853        let into = rt
9854            .create_note(&tok, "observation", None, "into note", None, None, vec![])
9855            .await
9856            .unwrap();
9857        let from = rt
9858            .create_note(&tok, "observation", None, "from note", None, None, vec![])
9859            .await
9860            .unwrap();
9861
9862        rt.merge_note_with_reason(
9863            &tok,
9864            into.id,
9865            from.id,
9866            EntityDedupMergePolicy::PreferInto,
9867            ContentMergeStrategy::Append,
9868            false,
9869            Some(String::new()),
9870        )
9871        .await
9872        .unwrap();
9873
9874        let events = rt
9875            .events(&tok)
9876            .unwrap()
9877            .query_events(
9878                khive_storage::EventFilter {
9879                    kinds: vec![EventKind::NoteMerged],
9880                    ..Default::default()
9881                },
9882                khive_storage::types::PageRequest {
9883                    offset: 0,
9884                    limit: 10,
9885                },
9886            )
9887            .await
9888            .unwrap();
9889        assert_eq!(events.items.len(), 1);
9890        assert_eq!(
9891            events.items[0].payload.get("reason"),
9892            Some(&Value::String(String::new()))
9893        );
9894    }
9895
9896    #[tokio::test]
9897    async fn merge_note_with_reason_rejects_secrets_before_reads_or_writes() {
9898        let rt = rt();
9899        let tok = NamespaceToken::local();
9900        let into = rt
9901            .create_note(&tok, "observation", None, "into note", None, None, vec![])
9902            .await
9903            .unwrap();
9904        let from = rt
9905            .create_note(&tok, "observation", None, "from note", None, None, vec![])
9906            .await
9907            .unwrap();
9908        let secret = secret_shaped_reason();
9909
9910        let error = rt
9911            .merge_note_with_reason(
9912                &tok,
9913                into.id,
9914                from.id,
9915                EntityDedupMergePolicy::PreferInto,
9916                ContentMergeStrategy::Append,
9917                false,
9918                Some(secret),
9919            )
9920            .await
9921            .unwrap_err();
9922
9923        assert!(matches!(error, RuntimeError::SecretDetected(_)));
9924        let note_store = rt.notes(&tok).unwrap();
9925        assert_eq!(
9926            note_store.get_note(into.id).await.unwrap().unwrap().id,
9927            into.id
9928        );
9929        assert_eq!(
9930            note_store.get_note(from.id).await.unwrap().unwrap().id,
9931            from.id
9932        );
9933        let event_count = rt
9934            .events(&tok)
9935            .unwrap()
9936            .count_events(khive_storage::EventFilter {
9937                kinds: vec![EventKind::NoteMerged],
9938                ..Default::default()
9939            })
9940            .await
9941            .unwrap();
9942        assert_eq!(event_count, 0);
9943    }
9944
9945    #[tokio::test]
9946    async fn legacy_merge_methods_remain_source_compatible() {
9947        let rt = rt();
9948        let tok = NamespaceToken::local();
9949        let into_entity = rt
9950            .create_entity(&tok, "concept", None, "Entity A", None, None, vec![])
9951            .await
9952            .unwrap();
9953        let from_entity = rt
9954            .create_entity(&tok, "concept", None, "Entity B", None, None, vec![])
9955            .await
9956            .unwrap();
9957        let into_note = rt
9958            .create_note(&tok, "observation", None, "note A", None, None, vec![])
9959            .await
9960            .unwrap();
9961        let from_note = rt
9962            .create_note(&tok, "observation", None, "note B", None, None, vec![])
9963            .await
9964            .unwrap();
9965
9966        rt.merge_entity(
9967            &tok,
9968            into_entity.id,
9969            from_entity.id,
9970            EntityDedupMergePolicy::PreferInto,
9971            ContentMergeStrategy::Append,
9972            false,
9973        )
9974        .await
9975        .unwrap();
9976        rt.merge_note(
9977            &tok,
9978            into_note.id,
9979            from_note.id,
9980            EntityDedupMergePolicy::PreferInto,
9981            ContentMergeStrategy::Append,
9982            false,
9983        )
9984        .await
9985        .unwrap();
9986    }
9987
9988    // ---- interim merged_into miss-hint (data-integrity, precedes ADR-113 chase) ----
9989
9990    #[tokio::test]
9991    async fn get_entity_after_merge_discloses_kept_id() {
9992        let rt = rt();
9993        let tok = NamespaceToken::local();
9994        let into = rt
9995            .create_entity(&tok, "concept", None, "Kept", None, None, vec![])
9996            .await
9997            .unwrap();
9998        let from = rt
9999            .create_entity(&tok, "concept", None, "Absorbed", None, None, vec![])
10000            .await
10001            .unwrap();
10002
10003        rt.merge_entity(
10004            &tok,
10005            into.id,
10006            from.id,
10007            EntityDedupMergePolicy::PreferInto,
10008            ContentMergeStrategy::Append,
10009            false,
10010        )
10011        .await
10012        .unwrap();
10013
10014        let err = rt.get_entity(&tok, from.id).await.unwrap_err();
10015        let msg = err.to_string();
10016        assert!(
10017            msg.contains("was merged into") && msg.contains(&into.id.to_string()),
10018            "expected a merged_into disclosure naming {}, got {msg:?}",
10019            into.id
10020        );
10021    }
10022
10023    /// A row an earlier restore left live over its merge (deleted_at cleared,
10024    /// merged_into kept) is an invariant violation, not a state restore may
10025    /// report as "already live". Restore names it and writes nothing.
10026    #[tokio::test]
10027    async fn restore_names_a_live_row_that_still_carries_merged_into() {
10028        let rt = rt();
10029        let tok = NamespaceToken::local();
10030        let into = rt
10031            .create_entity(&tok, "concept", None, "Kept", None, None, vec![])
10032            .await
10033            .unwrap();
10034        let from = rt
10035            .create_entity(&tok, "concept", None, "Absorbed", None, None, vec![])
10036            .await
10037            .unwrap();
10038        rt.merge_entity(
10039            &tok,
10040            into.id,
10041            from.id,
10042            EntityDedupMergePolicy::PreferInto,
10043            ContentMergeStrategy::Append,
10044            false,
10045        )
10046        .await
10047        .unwrap();
10048        // Reproduce what the pre-guard restore wrote: the tombstone cleared,
10049        // the merge provenance left in place.
10050        let mut writer = rt.sql().writer().await.expect("sql writer");
10051        let cleared = writer
10052            .execute(khive_storage::SqlStatement {
10053                sql: "UPDATE entities SET version = version + 1, deleted_at = NULL \
10054                      WHERE id = ?1 AND merged_into IS NOT NULL"
10055                    .to_string(),
10056                params: vec![SqlValue::Text(from.id.to_string())],
10057                label: None,
10058            })
10059            .await
10060            .expect("seed the pre-guard state");
10061        assert_eq!(
10062            cleared, 1,
10063            "control: the seed must have found the merge tombstone"
10064        );
10065        drop(writer);
10066
10067        let err = rt.restore_entity(&tok, from.id).await.unwrap_err();
10068        let msg = err.to_string();
10069        assert!(
10070            msg.contains("live_merged_entity") && msg.contains(&into.id.to_string()),
10071            "restore of a live merged row must be named, not reported already live, got {msg:?}"
10072        );
10073
10074        // Nothing was written: the row is still live and still carries the merge.
10075        let row = rt
10076            .get_entity_including_deleted(&tok, from.id)
10077            .await
10078            .unwrap()
10079            .expect("row exists");
10080        assert!(row.deleted_at.is_none());
10081        assert_eq!(row.merged_into, Some(into.id));
10082    }
10083
10084    #[tokio::test]
10085    async fn restore_refuses_a_merge_tombstone_and_keeps_the_disclosure() {
10086        let rt = rt();
10087        let tok = NamespaceToken::local();
10088        let into = rt
10089            .create_entity(&tok, "concept", None, "Kept", None, None, vec![])
10090            .await
10091            .unwrap();
10092        let from = rt
10093            .create_entity(&tok, "concept", None, "Absorbed", None, None, vec![])
10094            .await
10095            .unwrap();
10096        rt.merge_entity(
10097            &tok,
10098            into.id,
10099            from.id,
10100            EntityDedupMergePolicy::PreferInto,
10101            ContentMergeStrategy::Append,
10102            false,
10103        )
10104        .await
10105        .unwrap();
10106
10107        let err = rt.restore_entity(&tok, from.id).await.unwrap_err();
10108        let msg = err.to_string();
10109        assert!(
10110            msg.contains("merge_tombstone") && msg.contains(&into.id.to_string()),
10111            "restore of a merge tombstone must be refused naming the kept id, got {msg:?}"
10112        );
10113
10114        // The refusal wrote nothing: the source is still a merge tombstone.
10115        let err = rt.get_entity(&tok, from.id).await.unwrap_err();
10116        let msg = err.to_string();
10117        assert!(
10118            msg.contains("was merged into") && msg.contains(&into.id.to_string()),
10119            "after a refused restore the merged_into disclosure must survive, got {msg:?}"
10120        );
10121        let tombstone = rt
10122            .get_entity_including_deleted(&tok, from.id)
10123            .await
10124            .unwrap()
10125            .expect("tombstone row still present");
10126        assert!(tombstone.deleted_at.is_some());
10127        assert_eq!(tombstone.merged_into, Some(into.id));
10128    }
10129
10130    #[tokio::test]
10131    async fn merge_tombstone_carries_the_id_of_its_merge_event() {
10132        let rt = rt();
10133        let tok = NamespaceToken::local();
10134        let into = rt
10135            .create_entity(&tok, "concept", None, "Kept", None, None, vec![])
10136            .await
10137            .unwrap();
10138        let from = rt
10139            .create_entity(&tok, "concept", None, "Absorbed", None, None, vec![])
10140            .await
10141            .unwrap();
10142        rt.merge_entity(
10143            &tok,
10144            into.id,
10145            from.id,
10146            EntityDedupMergePolicy::PreferInto,
10147            ContentMergeStrategy::Append,
10148            false,
10149        )
10150        .await
10151        .unwrap();
10152
10153        let events = rt
10154            .events(&tok)
10155            .unwrap()
10156            .query_events(
10157                khive_storage::EventFilter {
10158                    kinds: vec![EventKind::EntityMerged],
10159                    ..Default::default()
10160                },
10161                khive_storage::types::PageRequest {
10162                    offset: 0,
10163                    limit: 10,
10164                },
10165            )
10166            .await
10167            .unwrap();
10168        let [event] = events.items.as_slice() else {
10169            panic!(
10170                "expected one EntityMerged event, got {}",
10171                events.items.len()
10172            );
10173        };
10174        assert_eq!(event.payload["from_id"], serde_json::json!(from.id));
10175
10176        let tombstone = rt
10177            .get_entity_including_deleted(&tok, from.id)
10178            .await
10179            .unwrap()
10180            .expect("tombstone row still present");
10181        assert_eq!(
10182            tombstone.merge_event_id,
10183            Some(event.id),
10184            "the tombstone must name the event that recorded its merge"
10185        );
10186        let kept = rt.get_entity(&tok, into.id).await.unwrap();
10187        assert_eq!(kept.merge_event_id, None);
10188    }
10189
10190    #[tokio::test]
10191    async fn get_entity_on_plain_soft_delete_stays_bare_not_found() {
10192        let rt = rt();
10193        let tok = NamespaceToken::local();
10194        let entity = rt
10195            .create_entity(&tok, "concept", None, "Deleted", None, None, vec![])
10196            .await
10197            .unwrap();
10198        assert!(rt.delete_entity(&tok, entity.id, false).await.unwrap());
10199
10200        let err = rt.get_entity(&tok, entity.id).await.unwrap_err();
10201        let msg = err.to_string();
10202        assert!(
10203            !msg.contains("merged into"),
10204            "plain soft-delete must not gain a merge hint, got {msg:?}"
10205        );
10206        assert_eq!(msg, format!("not found: entity {}", entity.id));
10207    }
10208
10209    #[tokio::test]
10210    async fn get_entity_on_absent_id_stays_bare_not_found() {
10211        let rt = rt();
10212        let tok = NamespaceToken::local();
10213        let absent = Uuid::new_v4();
10214
10215        let err = rt.get_entity(&tok, absent).await.unwrap_err();
10216        let msg = err.to_string();
10217        assert!(
10218            !msg.contains("merged into"),
10219            "a never-existed id must not gain a merge hint, got {msg:?}"
10220        );
10221        assert_eq!(msg, format!("not found: entity {absent}"));
10222    }
10223
10224    // ---- merge helper unit tests ----
10225
10226    #[test]
10227    fn union_tags_deduplicates() {
10228        let (tags, added) = union_tags(
10229            &["x".to_string(), "y".to_string()],
10230            &["y".to_string(), "z".to_string()],
10231        );
10232        let mut sorted = tags.clone();
10233        sorted.sort();
10234        assert_eq!(sorted, vec!["x", "y", "z"]);
10235        assert_eq!(added, 1);
10236    }
10237
10238    #[test]
10239    fn merge_properties_prefer_into_fills_missing_keys() {
10240        let a = serde_json::json!({"a": 1});
10241        let b = serde_json::json!({"a": 99, "b": 2});
10242        let (merged, added) =
10243            merge_properties(&Some(a), &Some(b), EntityDedupMergePolicy::PreferInto);
10244        let m = merged.unwrap();
10245        assert_eq!(m["a"], 1);
10246        assert_eq!(m["b"], 2);
10247        assert_eq!(added, 1);
10248    }
10249
10250    // ---- tombstone and note merge tests ----
10251
10252    #[tokio::test]
10253    async fn merge_entity_tombstones_source_with_provenance() {
10254        let rt = rt();
10255        let tok = NamespaceToken::local();
10256        let into = rt
10257            .create_entity(&tok, "concept", None, "Into", None, None, vec![])
10258            .await
10259            .unwrap();
10260        let from = rt
10261            .create_entity(&tok, "concept", None, "From", None, None, vec![])
10262            .await
10263            .unwrap();
10264        let from_id = from.id;
10265
10266        rt.merge_entity_with_reason(
10267            &tok,
10268            into.id,
10269            from_id,
10270            EntityDedupMergePolicy::PreferInto,
10271            ContentMergeStrategy::Append,
10272            false,
10273            None,
10274        )
10275        .await
10276        .unwrap();
10277
10278        assert!(
10279            rt.get_entity(&tok, from_id).await.is_err(),
10280            "tombstoned source should not be returned by get_entity"
10281        );
10282
10283        let pool = rt.backend().pool_arc();
10284        let (deleted_at, merged_into): (Option<i64>, Option<String>) =
10285            tokio::task::spawn_blocking(move || {
10286                let guard = pool.writer().unwrap();
10287                guard
10288                    .conn()
10289                    .query_row(
10290                        "SELECT deleted_at, merged_into FROM entities WHERE id = ?1",
10291                        [from_id.to_string()],
10292                        |row| Ok((row.get(0)?, row.get(1)?)),
10293                    )
10294                    .unwrap()
10295            })
10296            .await
10297            .unwrap();
10298        assert!(
10299            deleted_at.is_some(),
10300            "tombstoned entity must have deleted_at set"
10301        );
10302        assert_eq!(
10303            merged_into.as_deref(),
10304            Some(into.id.to_string().as_str()),
10305            "merged_into must point to into_id"
10306        );
10307    }
10308
10309    #[tokio::test]
10310    async fn generic_update_and_merge_reject_schedule_managed_notes() {
10311        let rt = rt();
10312        let tok = NamespaceToken::local();
10313        let schedule_a = rt
10314            .create_note(
10315                &tok,
10316                "scheduled_event",
10317                None,
10318                "stats()",
10319                None,
10320                Some(serde_json::json!({
10321                    "event_type": "schedule",
10322                    "payload": "stats()",
10323                    "status": "pending",
10324                    "trigger_at": "2099-01-01T00:00:00Z"
10325                })),
10326                vec![],
10327            )
10328            .await
10329            .unwrap();
10330        let schedule_b = rt
10331            .create_note(
10332                &tok,
10333                "scheduled_event",
10334                None,
10335                "stats()",
10336                None,
10337                Some(serde_json::json!({
10338                    "event_type": "schedule",
10339                    "payload": "stats()",
10340                    "status": "pending",
10341                    "trigger_at": "2099-01-02T00:00:00Z"
10342                })),
10343                vec![],
10344            )
10345            .await
10346            .unwrap();
10347
10348        let update_error = rt
10349            .update_note(
10350                &tok,
10351                schedule_a.id,
10352                NotePatch::new(
10353                    None,
10354                    None,
10355                    None,
10356                    None,
10357                    Some(serde_json::json!({ "payload": "delete(id=\"victim\")" })),
10358                ),
10359            )
10360            .await
10361            .expect_err("schedule-managed note update must fail");
10362        assert!(
10363            update_error.to_string().contains("schedule-managed"),
10364            "{update_error}"
10365        );
10366
10367        for (into_id, from_id) in [
10368            (schedule_a.id, schedule_b.id),
10369            (schedule_b.id, schedule_a.id),
10370        ] {
10371            let merge_error = rt
10372                .merge_note(
10373                    &tok,
10374                    into_id,
10375                    from_id,
10376                    EntityDedupMergePolicy::PreferFrom,
10377                    ContentMergeStrategy::PreferFrom,
10378                    false,
10379                )
10380                .await
10381                .expect_err("either schedule-managed merge operand must fail");
10382            assert!(
10383                merge_error.to_string().contains("schedule-managed"),
10384                "{merge_error}"
10385            );
10386        }
10387
10388        let store = rt.notes(&tok).unwrap();
10389        for (id, trigger_at) in [
10390            (schedule_a.id, "2099-01-01T00:00:00Z"),
10391            (schedule_b.id, "2099-01-02T00:00:00Z"),
10392        ] {
10393            let note = store
10394                .get_note(id)
10395                .await
10396                .unwrap()
10397                .expect("rejected generic mutation leaves the schedule intact");
10398            assert_eq!(note.properties.as_ref().unwrap()["trigger_at"], trigger_at);
10399        }
10400    }
10401
10402    #[tokio::test]
10403    async fn merge_note_refuses_quarantined_message_in_either_role() {
10404        let rt = rt();
10405        let tok = NamespaceToken::local();
10406        let capability = crate::pack::ChannelIngestCapability { _sealed: () };
10407        let ordinary = rt
10408            .create_note(
10409                &tok,
10410                "message",
10411                None,
10412                "ordinary message",
10413                None,
10414                None,
10415                vec![],
10416            )
10417            .await
10418            .unwrap();
10419        let quarantined = rt
10420            .try_create_note_as_trusted_ingest(
10421                &capability,
10422                &tok,
10423                "message",
10424                None,
10425                "quarantined transport content",
10426                Some(serde_json::json!({"quarantined": true})),
10427            )
10428            .await
10429            .unwrap()
10430            .expect("quarantined insert");
10431
10432        for (into_id, from_id) in [(ordinary.id, quarantined.id), (quarantined.id, ordinary.id)] {
10433            let error = rt
10434                .merge_note(
10435                    &tok,
10436                    into_id,
10437                    from_id,
10438                    EntityDedupMergePolicy::PreferFrom,
10439                    ContentMergeStrategy::Append,
10440                    false,
10441                )
10442                .await
10443                .expect_err("a quarantined message must not merge in either role");
10444            assert!(error.to_string().contains("quarantined"), "{error}");
10445        }
10446
10447        // Neither operand was mutated by the refused merges.
10448        let store = rt.notes(&tok).unwrap();
10449        let kept = store
10450            .get_note(quarantined.id)
10451            .await
10452            .unwrap()
10453            .expect("quarantined note intact");
10454        assert_eq!(
10455            kept.properties.as_ref().unwrap()["quarantined"],
10456            serde_json::json!(true)
10457        );
10458        assert_eq!(kept.content, "quarantined transport content");
10459    }
10460
10461    #[tokio::test]
10462    async fn merge_note_refuses_string_encoded_quarantine_marker() {
10463        let rt = rt();
10464        let tok = NamespaceToken::local();
10465        let capability = crate::pack::ChannelIngestCapability { _sealed: () };
10466        let ordinary = rt
10467            .create_note(
10468                &tok,
10469                "message",
10470                None,
10471                "ordinary message",
10472                None,
10473                None,
10474                vec![],
10475            )
10476            .await
10477            .unwrap();
10478        // Some channel adapters record the marker as the string "true".
10479        let quarantined = rt
10480            .try_create_note_as_trusted_ingest(
10481                &capability,
10482                &tok,
10483                "message",
10484                None,
10485                "string-marked quarantined content",
10486                Some(serde_json::json!({"quarantined": "true"})),
10487            )
10488            .await
10489            .unwrap()
10490            .expect("quarantined insert");
10491
10492        let error = rt
10493            .merge_note(
10494                &tok,
10495                ordinary.id,
10496                quarantined.id,
10497                EntityDedupMergePolicy::PreferFrom,
10498                ContentMergeStrategy::Append,
10499                false,
10500            )
10501            .await
10502            .expect_err("string-encoded quarantine marker must also refuse the merge");
10503        assert!(error.to_string().contains("quarantined"), "{error}");
10504    }
10505
10506    #[tokio::test]
10507    async fn merge_note_still_merges_unquarantined_messages() {
10508        let rt = rt();
10509        let tok = NamespaceToken::local();
10510        let into = rt
10511            .create_note(&tok, "message", None, "into message", None, None, vec![])
10512            .await
10513            .unwrap();
10514        let from = rt
10515            .create_note(&tok, "message", None, "from message", None, None, vec![])
10516            .await
10517            .unwrap();
10518        rt.merge_note(
10519            &tok,
10520            into.id,
10521            from.id,
10522            EntityDedupMergePolicy::PreferInto,
10523            ContentMergeStrategy::Append,
10524            false,
10525        )
10526        .await
10527        .expect("ordinary message merge must still work");
10528    }
10529
10530    #[tokio::test]
10531    async fn merge_note_same_kind_appends_content() {
10532        let rt = rt();
10533        let tok = NamespaceToken::local();
10534        let into = rt
10535            .create_note(
10536                &tok,
10537                "observation",
10538                None,
10539                "Into content",
10540                None,
10541                None,
10542                vec![],
10543            )
10544            .await
10545            .unwrap();
10546        let from = rt
10547            .create_note(
10548                &tok,
10549                "observation",
10550                None,
10551                "From content",
10552                None,
10553                None,
10554                vec![],
10555            )
10556            .await
10557            .unwrap();
10558        let from_id = from.id;
10559
10560        let summary = rt
10561            .merge_note_with_reason(
10562                &tok,
10563                into.id,
10564                from_id,
10565                EntityDedupMergePolicy::PreferInto,
10566                ContentMergeStrategy::Append,
10567                false,
10568                None,
10569            )
10570            .await
10571            .unwrap();
10572
10573        assert_eq!(summary.kept_id, into.id);
10574        assert_eq!(summary.removed_id, from_id);
10575        assert!(summary.content_appended);
10576        assert!(!summary.dry_run);
10577
10578        let from_store = rt.notes(&tok).unwrap();
10579        assert!(
10580            from_store.get_note(from_id).await.unwrap().is_none(),
10581            "merged-from note should be soft-deleted"
10582        );
10583    }
10584
10585    #[tokio::test]
10586    async fn merge_note_preserves_the_kept_memory_key() {
10587        use crate::keyed_memory::{create_keyed_memory, KeyedMemorySpec};
10588
10589        let rt = rt();
10590        let tok = NamespaceToken::local();
10591        let (into, _, _) = create_keyed_memory(
10592            &rt,
10593            &tok,
10594            KeyedMemorySpec {
10595                content: "Into keyed memory",
10596                key: "kept-memory-key",
10597                salience: 0.7,
10598                decay_factor: 0.0,
10599                properties: serde_json::json!({}),
10600                source_id: None,
10601                embedding_model: None,
10602            },
10603        )
10604        .await
10605        .unwrap();
10606        let from = rt
10607            .create_note(&tok, "memory", None, "From memory", None, None, vec![])
10608            .await
10609            .unwrap();
10610
10611        let summary = rt
10612            .merge_note(
10613                &tok,
10614                into.id,
10615                from.id,
10616                EntityDedupMergePolicy::PreferInto,
10617                ContentMergeStrategy::Append,
10618                false,
10619            )
10620            .await
10621            .expect("merge binds every stored note field");
10622        assert_eq!(summary.kept_id, into.id);
10623        let stored = rt
10624            .notes(&tok)
10625            .unwrap()
10626            .get_note(into.id)
10627            .await
10628            .unwrap()
10629            .unwrap();
10630        assert_eq!(stored.key.as_deref(), Some("kept-memory-key"));
10631        assert!(stored.content.contains("Into keyed memory"));
10632        assert!(stored.content.contains("From memory"));
10633        assert!(rt
10634            .notes(&tok)
10635            .unwrap()
10636            .get_note(from.id)
10637            .await
10638            .unwrap()
10639            .is_none());
10640    }
10641
10642    // Note merge must absorb a conflicting edge natural key exactly like entity
10643    // merge does, since both route through the shared EDGE_SYMMETRIC_*_SQL arms.
10644    #[tokio::test]
10645    async fn merge_note_survives_shared_edge_to_third_party() {
10646        use khive_storage::EdgeRelation;
10647        let rt = rt();
10648        let tok = NamespaceToken::local();
10649
10650        let into = rt
10651            .create_note(&tok, "observation", None, "Into", None, None, vec![])
10652            .await
10653            .unwrap();
10654        let from = rt
10655            .create_note(&tok, "observation", None, "From", None, None, vec![])
10656            .await
10657            .unwrap();
10658        let shared = rt
10659            .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
10660            .await
10661            .unwrap();
10662
10663        // Both into and from annotate the same shared entity — rewiring from's
10664        // edge onto into during merge produces a duplicate (into, shared,
10665        // annotates) triple, exercising the conflict-probe/delete arms.
10666        rt.link(&tok, into.id, shared.id, EdgeRelation::Annotates, 1.0, None)
10667            .await
10668            .unwrap();
10669        rt.link(&tok, from.id, shared.id, EdgeRelation::Annotates, 1.0, None)
10670            .await
10671            .unwrap();
10672
10673        let summary = rt
10674            .merge_note_with_reason(
10675                &tok,
10676                into.id,
10677                from.id,
10678                EntityDedupMergePolicy::PreferInto,
10679                ContentMergeStrategy::Append,
10680                false,
10681                None,
10682            )
10683            .await
10684            .expect("merge must succeed even when both notes annotate the same entity");
10685
10686        assert_eq!(summary.kept_id, into.id);
10687        assert_eq!(summary.removed_id, from.id);
10688
10689        let into_edges = rt
10690            .list_edges(
10691                &tok,
10692                crate::EdgeListFilter {
10693                    source_id: Some(into.id),
10694                    target_id: Some(shared.id),
10695                    relations: vec![EdgeRelation::Annotates],
10696                    ..Default::default()
10697                },
10698                10,
10699                0,
10700            )
10701            .await
10702            .unwrap();
10703        assert_eq!(
10704            into_edges.len(),
10705            1,
10706            "exactly one live into→shared annotates edge must exist after merge; got: {into_edges:?}"
10707        );
10708    }
10709
10710    #[tokio::test]
10711    async fn merge_note_conflict_records_dropped_edge_and_cascades_annotation() {
10712        let rt = rt();
10713        let tok = NamespaceToken::local();
10714        let into = rt
10715            .create_note(&tok, "observation", None, "Into", None, None, vec![])
10716            .await
10717            .unwrap();
10718        let from = rt
10719            .create_note(&tok, "observation", None, "From", None, None, vec![])
10720            .await
10721            .unwrap();
10722        let annotator = rt
10723            .create_note(
10724                &tok,
10725                "observation",
10726                None,
10727                "edge annotation",
10728                None,
10729                None,
10730                vec![],
10731            )
10732            .await
10733            .unwrap();
10734        let shared = rt
10735            .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
10736            .await
10737            .unwrap();
10738
10739        let survivor = rt
10740            .link(
10741                &tok,
10742                into.id,
10743                shared.id,
10744                EdgeRelation::Annotates,
10745                1.0,
10746                Some(serde_json::json!({"source": "survivor"})),
10747            )
10748            .await
10749            .unwrap();
10750        let dropped = rt
10751            .link(
10752                &tok,
10753                from.id,
10754                shared.id,
10755                EdgeRelation::Annotates,
10756                0.4,
10757                Some(serde_json::json!({"source": "dropped"})),
10758            )
10759            .await
10760            .unwrap();
10761        let annotation = rt
10762            .link(
10763                &tok,
10764                annotator.id,
10765                dropped.id.into(),
10766                EdgeRelation::Annotates,
10767                0.8,
10768                Some(serde_json::json!({"why": "duplicate claim"})),
10769            )
10770            .await
10771            .unwrap();
10772        rt.delete_edge(&tok, annotation.id.into(), false)
10773            .await
10774            .unwrap();
10775
10776        let summary = rt
10777            .merge_note(
10778                &tok,
10779                into.id,
10780                from.id,
10781                EntityDedupMergePolicy::PreferInto,
10782                ContentMergeStrategy::Append,
10783                false,
10784            )
10785            .await
10786            .unwrap();
10787
10788        let [conflict] = summary.edge_conflict_preimages.as_slice() else {
10789            panic!(
10790                "expected one note-merge edge conflict, got {:?}",
10791                summary.edge_conflict_preimages
10792            );
10793        };
10794        assert_eq!(conflict.surviving_edge_id, Uuid::from(survivor.id));
10795        assert_eq!(conflict.dropped_edge.id, Uuid::from(dropped.id));
10796        assert_eq!(conflict.dropped_edge.source_id, from.id);
10797        assert_eq!(conflict.dropped_edge.weight, 0.4);
10798        assert_eq!(
10799            conflict.dropped_edge.metadata,
10800            Some(serde_json::json!({"source": "dropped"}))
10801        );
10802        assert_eq!(conflict.incident_edge_preimages.len(), 1);
10803        assert_eq!(
10804            conflict.incident_edge_preimages[0].id,
10805            Uuid::from(annotation.id)
10806        );
10807        assert_eq!(
10808            conflict.incident_edge_preimages[0].metadata,
10809            Some(serde_json::json!({"why": "duplicate claim"}))
10810        );
10811        assert!(
10812            conflict.incident_edge_preimages[0].deleted_at.is_some(),
10813            "the cascade preimage must retain an annotation's tombstone state"
10814        );
10815        assert!(rt
10816            .get_edge_including_deleted(&tok, dropped.id.into())
10817            .await
10818            .unwrap()
10819            .is_none());
10820        assert!(
10821            rt.get_edge_including_deleted(&tok, annotation.id.into())
10822                .await
10823                .unwrap()
10824                .is_none(),
10825            "annotation targeting the dropped edge must be cascaded, not left dangling"
10826        );
10827
10828        let events = rt
10829            .events(&tok)
10830            .unwrap()
10831            .query_events(
10832                khive_storage::EventFilter {
10833                    kinds: vec![EventKind::NoteMerged],
10834                    ..Default::default()
10835                },
10836                khive_storage::types::PageRequest {
10837                    offset: 0,
10838                    limit: 10,
10839                },
10840            )
10841            .await
10842            .unwrap();
10843        assert_eq!(events.items.len(), 1);
10844        assert_eq!(
10845            events.items[0].payload["edge_conflict_preimages"],
10846            serde_json::to_value(&summary.edge_conflict_preimages).unwrap()
10847        );
10848    }
10849
10850    // A dry run must predict the same conflict preimages a committing note
10851    // merge would produce, without deleting or mutating a single row. The
10852    // incident cascade is two levels deep (an annotation on the dropped
10853    // edge, and a nested annotation on that annotation) so the root-to-leaf
10854    // ordering ADR-014 promises is actually exercised, not just a
10855    // one-element vec that trivially satisfies any order. Every row touched
10856    // by the merge — both notes and every edge — is snapshotted before the
10857    // dry run and compared field-for-field against its post-run state.
10858    #[tokio::test]
10859    async fn merge_note_dry_run_conflict_returns_preimages_without_mutating() {
10860        let rt = rt();
10861        let tok = NamespaceToken::local();
10862        let into = rt
10863            .create_note(&tok, "observation", None, "Into", None, None, vec![])
10864            .await
10865            .unwrap();
10866        let from = rt
10867            .create_note(&tok, "observation", None, "From", None, None, vec![])
10868            .await
10869            .unwrap();
10870        let annotator = rt
10871            .create_note(
10872                &tok,
10873                "observation",
10874                None,
10875                "edge annotation",
10876                None,
10877                None,
10878                vec![],
10879            )
10880            .await
10881            .unwrap();
10882        let nested_annotator = rt
10883            .create_note(
10884                &tok,
10885                "observation",
10886                None,
10887                "nested edge annotation",
10888                None,
10889                None,
10890                vec![],
10891            )
10892            .await
10893            .unwrap();
10894        let shared = rt
10895            .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
10896            .await
10897            .unwrap();
10898
10899        let survivor = rt
10900            .link(
10901                &tok,
10902                into.id,
10903                shared.id,
10904                EdgeRelation::Annotates,
10905                1.0,
10906                Some(serde_json::json!({"source": "survivor"})),
10907            )
10908            .await
10909            .unwrap();
10910        let dropped = rt
10911            .link(
10912                &tok,
10913                from.id,
10914                shared.id,
10915                EdgeRelation::Annotates,
10916                0.4,
10917                Some(serde_json::json!({"source": "dropped"})),
10918            )
10919            .await
10920            .unwrap();
10921        let annotation = rt
10922            .link(
10923                &tok,
10924                annotator.id,
10925                dropped.id.into(),
10926                EdgeRelation::Annotates,
10927                0.8,
10928                Some(serde_json::json!({"why": "duplicate claim"})),
10929            )
10930            .await
10931            .unwrap();
10932        let nested_annotation = rt
10933            .link(
10934                &tok,
10935                nested_annotator.id,
10936                annotation.id.into(),
10937                EdgeRelation::Annotates,
10938                0.6,
10939                Some(serde_json::json!({"why": "nested duplicate claim"})),
10940            )
10941            .await
10942            .unwrap();
10943        rt.delete_edge(&tok, nested_annotation.id.into(), false)
10944            .await
10945            .unwrap();
10946
10947        let survivor_before = rt
10948            .get_edge_including_deleted(&tok, survivor.id.into())
10949            .await
10950            .unwrap()
10951            .expect("survivor edge exists");
10952        let dropped_before = rt
10953            .get_edge_including_deleted(&tok, dropped.id.into())
10954            .await
10955            .unwrap()
10956            .expect("dropped edge exists");
10957        let annotation_before = rt
10958            .get_edge_including_deleted(&tok, annotation.id.into())
10959            .await
10960            .unwrap()
10961            .expect("annotation edge exists");
10962        let nested_annotation_before = rt
10963            .get_edge_including_deleted(&tok, nested_annotation.id.into())
10964            .await
10965            .unwrap()
10966            .expect("nested annotation edge exists");
10967        let into_before = rt
10968            .get_note_including_deleted(&tok, into.id)
10969            .await
10970            .unwrap()
10971            .expect("into note exists");
10972        let from_before = rt
10973            .get_note_including_deleted(&tok, from.id)
10974            .await
10975            .unwrap()
10976            .expect("from note exists");
10977
10978        let summary = rt
10979            .merge_note(
10980                &tok,
10981                into.id,
10982                from.id,
10983                EntityDedupMergePolicy::PreferInto,
10984                ContentMergeStrategy::Append,
10985                true,
10986            )
10987            .await
10988            .unwrap();
10989
10990        let [conflict] = summary.edge_conflict_preimages.as_slice() else {
10991            panic!(
10992                "expected one note-merge edge conflict from the dry run, got {:?}",
10993                summary.edge_conflict_preimages
10994            );
10995        };
10996        assert_eq!(conflict.surviving_edge_id, Uuid::from(survivor.id));
10997        assert_eq!(conflict.dropped_edge.id, Uuid::from(dropped.id));
10998        assert_eq!(conflict.dropped_edge.source_id, from.id);
10999        assert_eq!(conflict.dropped_edge.weight, 0.4);
11000        // Root-to-leaf order (ADR-014): the direct annotation on the dropped
11001        // edge must precede the annotation nested on top of it.
11002        assert_eq!(conflict.incident_edge_preimages.len(), 2);
11003        assert_eq!(
11004            conflict.incident_edge_preimages[0].id,
11005            Uuid::from(annotation.id)
11006        );
11007        assert!(
11008            conflict.incident_edge_preimages[0].deleted_at.is_none(),
11009            "the direct annotation was never soft-deleted"
11010        );
11011        assert_eq!(
11012            conflict.incident_edge_preimages[1].id,
11013            Uuid::from(nested_annotation.id)
11014        );
11015        assert!(
11016            conflict.incident_edge_preimages[1].deleted_at.is_some(),
11017            "dry-run preimage must retain the nested annotation's tombstone state"
11018        );
11019
11020        let survivor_after = rt
11021            .get_edge_including_deleted(&tok, survivor.id.into())
11022            .await
11023            .unwrap()
11024            .expect("dry run must not delete the survivor edge");
11025        let dropped_after = rt
11026            .get_edge_including_deleted(&tok, dropped.id.into())
11027            .await
11028            .unwrap()
11029            .expect("dry run must not delete the dropped edge");
11030        let annotation_after = rt
11031            .get_edge_including_deleted(&tok, annotation.id.into())
11032            .await
11033            .unwrap()
11034            .expect("dry run must not delete the cascaded annotation");
11035        let nested_annotation_after = rt
11036            .get_edge_including_deleted(&tok, nested_annotation.id.into())
11037            .await
11038            .unwrap()
11039            .expect("dry run must not delete the nested cascaded annotation");
11040        assert_eq!(
11041            serde_json::to_value(&survivor_before).unwrap(),
11042            serde_json::to_value(&survivor_after).unwrap(),
11043            "dry run must not mutate the surviving edge's row at all"
11044        );
11045        assert_eq!(
11046            serde_json::to_value(&dropped_before).unwrap(),
11047            serde_json::to_value(&dropped_after).unwrap(),
11048            "dry run must not mutate the would-be-dropped edge's row at all"
11049        );
11050        assert_eq!(
11051            serde_json::to_value(&annotation_before).unwrap(),
11052            serde_json::to_value(&annotation_after).unwrap(),
11053            "dry run must not mutate the incident annotation's row at all"
11054        );
11055        assert_eq!(
11056            serde_json::to_value(&nested_annotation_before).unwrap(),
11057            serde_json::to_value(&nested_annotation_after).unwrap(),
11058            "dry run must not mutate the nested incident annotation's row at all"
11059        );
11060
11061        let into_after = rt
11062            .get_note_including_deleted(&tok, into.id)
11063            .await
11064            .unwrap()
11065            .expect("into note must remain unmerged after a dry run");
11066        let from_after = rt
11067            .get_note_including_deleted(&tok, from.id)
11068            .await
11069            .unwrap()
11070            .expect("from note must not be deleted by a dry run");
11071        assert_eq!(
11072            serde_json::to_value(&into_before).unwrap(),
11073            serde_json::to_value(&into_after).unwrap(),
11074            "dry run must not mutate the into note's row at all"
11075        );
11076        assert_eq!(
11077            serde_json::to_value(&from_before).unwrap(),
11078            serde_json::to_value(&from_after).unwrap(),
11079            "dry run must not mutate the from note's row at all"
11080        );
11081        assert_eq!(from_after.status, from_before.status);
11082        assert_eq!(from_after.deleted_at, None);
11083
11084        let events = rt
11085            .events(&tok)
11086            .unwrap()
11087            .query_events(
11088                khive_storage::EventFilter {
11089                    kinds: vec![EventKind::NoteMerged],
11090                    ..Default::default()
11091                },
11092                khive_storage::types::PageRequest {
11093                    offset: 0,
11094                    limit: 10,
11095                },
11096            )
11097            .await
11098            .unwrap();
11099        assert!(
11100            events.items.is_empty(),
11101            "a dry run must not record a merge audit event"
11102        );
11103    }
11104
11105    // The rewire contract check must preserve note→note supersedes, supports,
11106    // and refutes — `validate_edge_relation_endpoints` permits any note→note
11107    // pair for these relations, so the merge matcher must too, or a note merge
11108    // deletes valid epistemic/supersession edges.
11109    #[tokio::test]
11110    async fn merge_note_preserves_note_to_note_epistemic_and_supersession_edges() {
11111        use khive_storage::EdgeRelation;
11112        let rt = rt();
11113        let tok = NamespaceToken::local();
11114
11115        let into = rt
11116            .create_note(&tok, "observation", None, "Into", None, None, vec![])
11117            .await
11118            .unwrap();
11119        let from = rt
11120            .create_note(&tok, "observation", None, "From", None, None, vec![])
11121            .await
11122            .unwrap();
11123        let superseded = rt
11124            .create_note(&tok, "observation", None, "Old", None, None, vec![])
11125            .await
11126            .unwrap();
11127        let claim = rt
11128            .create_note(&tok, "insight", None, "Claim", None, None, vec![])
11129            .await
11130            .unwrap();
11131        let counter = rt
11132            .create_note(&tok, "observation", None, "Counter", None, None, vec![])
11133            .await
11134            .unwrap();
11135
11136        // Outgoing from `from` (source rewires) and incoming onto `from`
11137        // (target rewires) — both directions must survive.
11138        rt.link(
11139            &tok,
11140            from.id,
11141            superseded.id,
11142            EdgeRelation::Supersedes,
11143            1.0,
11144            None,
11145        )
11146        .await
11147        .unwrap();
11148        rt.link(&tok, from.id, claim.id, EdgeRelation::Supports, 1.0, None)
11149            .await
11150            .unwrap();
11151        rt.link(&tok, counter.id, from.id, EdgeRelation::Refutes, 1.0, None)
11152            .await
11153            .unwrap();
11154
11155        let summary = rt
11156            .merge_note_with_reason(
11157                &tok,
11158                into.id,
11159                from.id,
11160                EntityDedupMergePolicy::PreferInto,
11161                ContentMergeStrategy::Append,
11162                false,
11163                None,
11164            )
11165            .await
11166            .unwrap();
11167
11168        assert_eq!(
11169            summary.edges_rewired, 3,
11170            "all three note→note edges must be rewired, not contract-dropped"
11171        );
11172        assert_eq!(
11173            summary.edges_contract_skipped, 0,
11174            "no valid note→note supersedes/supports/refutes edge may be dropped"
11175        );
11176
11177        for (src, tgt, rel) in [
11178            (into.id, superseded.id, EdgeRelation::Supersedes),
11179            (into.id, claim.id, EdgeRelation::Supports),
11180            (counter.id, into.id, EdgeRelation::Refutes),
11181        ] {
11182            let edges = rt
11183                .list_edges(
11184                    &tok,
11185                    crate::EdgeListFilter {
11186                        source_id: Some(src),
11187                        target_id: Some(tgt),
11188                        relations: vec![rel],
11189                        ..Default::default()
11190                    },
11191                    10,
11192                    0,
11193                )
11194                .await
11195                .unwrap();
11196            assert_eq!(
11197                edges.len(),
11198                1,
11199                "rewired {rel:?} edge {src}→{tgt} must survive the merge; got {edges:?}"
11200            );
11201        }
11202    }
11203
11204    /// The note path must leave its source and self-loop edge intact when the
11205    /// only durable preimage copy cannot be inserted into the event store.
11206    #[tokio::test]
11207    async fn note_merge_event_insert_failure_rolls_back_destructive_merge() {
11208        let rt = rt();
11209        let tok = NamespaceToken::local();
11210        let into = rt
11211            .create_note(&tok, "observation", None, "Into", None, None, vec![])
11212            .await
11213            .unwrap();
11214        let from = rt
11215            .create_note(&tok, "observation", None, "From", None, None, vec![])
11216            .await
11217            .unwrap();
11218        let edge = rt
11219            .link(&tok, into.id, from.id, EdgeRelation::Refutes, 1.0, None)
11220            .await
11221            .unwrap();
11222        let event_store = rt.events(&tok).unwrap();
11223        set_merge_event_refusal(&rt, "note_merged", true);
11224
11225        let failed = rt
11226            .merge_note(
11227                &tok,
11228                into.id,
11229                from.id,
11230                EntityDedupMergePolicy::PreferInto,
11231                ContentMergeStrategy::Append,
11232                false,
11233            )
11234            .await;
11235        assert!(failed.is_err(), "event insert must abort the note merge");
11236        let source = rt
11237            .notes(&tok)
11238            .unwrap()
11239            .get_note(from.id)
11240            .await
11241            .unwrap()
11242            .unwrap();
11243        assert!(source.deleted_at.is_none());
11244        assert!(
11245            rt.get_edge_including_deleted(&tok, edge.id.into())
11246                .await
11247                .unwrap()
11248                .is_some(),
11249            "the deleted self-loop must roll back"
11250        );
11251        let filter = khive_storage::EventFilter {
11252            kinds: vec![EventKind::NoteMerged],
11253            ..Default::default()
11254        };
11255        let page = khive_storage::types::PageRequest {
11256            offset: 0,
11257            limit: 10,
11258        };
11259        assert!(event_store
11260            .query_events(filter.clone(), page.clone())
11261            .await
11262            .unwrap()
11263            .items
11264            .is_empty());
11265
11266        set_merge_event_refusal(&rt, "note_merged", false);
11267        let summary = rt
11268            .merge_note(
11269                &tok,
11270                into.id,
11271                from.id,
11272                EntityDedupMergePolicy::PreferInto,
11273                ContentMergeStrategy::Append,
11274                false,
11275            )
11276            .await
11277            .unwrap();
11278        let tombstone = rt
11279            .notes(&tok)
11280            .unwrap()
11281            .get_note_including_deleted(from.id)
11282            .await
11283            .unwrap()
11284            .unwrap();
11285        assert!(tombstone.deleted_at.is_some());
11286        let events = event_store.query_events(filter, page).await.unwrap();
11287        assert_eq!(events.items.len(), 1);
11288        assert_eq!(
11289            events.items[0].payload["self_loop_edge_preimages"],
11290            serde_json::to_value(&summary.self_loop_edge_preimages).unwrap()
11291        );
11292    }
11293
11294    // The note-merge mirror of `merge_entity_drops_self_loops`. `into`
11295    // refutes `from` directly — merging `from` into `into` collapses this
11296    // into an into-refutes-into self-loop, which must be dropped and its
11297    // preimage captured and audited (khive#2934); before this fix a
11298    // refutation between the two merge operands vanished with no counter,
11299    // no preimage, and no audit trail.
11300    #[tokio::test]
11301    async fn merge_note_drops_self_loop_edge_records_preimage() {
11302        let rt = rt();
11303        let tok = NamespaceToken::local();
11304        let into = rt
11305            .create_note(&tok, "observation", None, "Into", None, None, vec![])
11306            .await
11307            .unwrap();
11308        let from = rt
11309            .create_note(&tok, "observation", None, "From", None, None, vec![])
11310            .await
11311            .unwrap();
11312
11313        let edge = rt
11314            .link(
11315                &tok,
11316                into.id,
11317                from.id,
11318                EdgeRelation::Refutes,
11319                0.85,
11320                Some(serde_json::json!({"basis": "direct contradiction"})),
11321            )
11322            .await
11323            .unwrap();
11324
11325        let summary = rt
11326            .merge_note(
11327                &tok,
11328                into.id,
11329                from.id,
11330                EntityDedupMergePolicy::PreferInto,
11331                ContentMergeStrategy::Append,
11332                false,
11333            )
11334            .await
11335            .unwrap();
11336
11337        assert_eq!(
11338            summary.edges_self_loop_dropped, 1,
11339            "the into-refutes-from edge becomes a self-loop and must be counted"
11340        );
11341        let [preimage] = summary.self_loop_edge_preimages.as_slice() else {
11342            panic!(
11343                "expected exactly one self-loop preimage, got {:?}",
11344                summary.self_loop_edge_preimages
11345            );
11346        };
11347        assert_eq!(preimage.id, Uuid::from(edge.id));
11348        assert_eq!(preimage.source_id, into.id);
11349        assert_eq!(preimage.target_id, from.id);
11350        assert_eq!(preimage.relation, "refutes");
11351        assert_eq!(preimage.weight, 0.85);
11352        assert_eq!(
11353            preimage.metadata,
11354            Some(serde_json::json!({"basis": "direct contradiction"}))
11355        );
11356
11357        assert!(
11358            rt.get_edge_including_deleted(&tok, edge.id.into())
11359                .await
11360                .unwrap()
11361                .is_none(),
11362            "the self-loop edge must actually be removed"
11363        );
11364
11365        let events = rt
11366            .events(&tok)
11367            .unwrap()
11368            .query_events(
11369                khive_storage::EventFilter {
11370                    kinds: vec![EventKind::NoteMerged],
11371                    ..Default::default()
11372                },
11373                khive_storage::types::PageRequest {
11374                    offset: 0,
11375                    limit: 10,
11376                },
11377            )
11378            .await
11379            .unwrap();
11380        assert_eq!(events.items.len(), 1);
11381        assert_eq!(
11382            events.items[0].payload["edges_self_loop_dropped"],
11383            serde_json::json!(1)
11384        );
11385        assert_eq!(
11386            events.items[0].payload["self_loop_edge_preimages"],
11387            serde_json::to_value(&summary.self_loop_edge_preimages).unwrap()
11388        );
11389    }
11390
11391    // Note-path counterpart of `merge_entity_self_loop_dry_run_matches_real_run`.
11392    #[tokio::test]
11393    async fn merge_note_self_loop_dry_run_matches_real_run() {
11394        let rt = rt();
11395        let tok = NamespaceToken::local();
11396        let into = rt
11397            .create_note(&tok, "observation", None, "Into", None, None, vec![])
11398            .await
11399            .unwrap();
11400        let from = rt
11401            .create_note(&tok, "observation", None, "From", None, None, vec![])
11402            .await
11403            .unwrap();
11404        let edge = rt
11405            .link(
11406                &tok,
11407                from.id,
11408                into.id,
11409                EdgeRelation::Supports,
11410                0.5,
11411                Some(serde_json::json!({"basis": "dry-run parity"})),
11412            )
11413            .await
11414            .unwrap();
11415
11416        let dry_summary = rt
11417            .merge_note(
11418                &tok,
11419                into.id,
11420                from.id,
11421                EntityDedupMergePolicy::PreferInto,
11422                ContentMergeStrategy::Append,
11423                true,
11424            )
11425            .await
11426            .unwrap();
11427
11428        assert!(
11429            rt.get_edge_including_deleted(&tok, edge.id.into())
11430                .await
11431                .unwrap()
11432                .is_some(),
11433            "dry run must not delete the self-loop edge"
11434        );
11435
11436        let real_summary = rt
11437            .merge_note(
11438                &tok,
11439                into.id,
11440                from.id,
11441                EntityDedupMergePolicy::PreferInto,
11442                ContentMergeStrategy::Append,
11443                false,
11444            )
11445            .await
11446            .unwrap();
11447
11448        assert_eq!(dry_summary.edges_self_loop_dropped, 1);
11449        let [dry_preimage] = dry_summary.self_loop_edge_preimages.as_slice() else {
11450            panic!(
11451                "expected exactly one predicted self-loop preimage, got {:?}",
11452                dry_summary.self_loop_edge_preimages
11453            );
11454        };
11455        assert_eq!(dry_preimage.id, Uuid::from(edge.id));
11456        assert_eq!(dry_preimage.source_id, from.id);
11457        assert_eq!(dry_preimage.target_id, into.id);
11458        assert_eq!(dry_preimage.relation, "supports");
11459        assert_eq!(dry_preimage.weight, 0.5);
11460        assert_eq!(
11461            dry_summary.edges_self_loop_dropped, real_summary.edges_self_loop_dropped,
11462            "a dry run must predict the same self-loop-drop count the committed merge produces"
11463        );
11464        assert_eq!(
11465            dry_summary.self_loop_edge_preimages, real_summary.self_loop_edge_preimages,
11466            "a dry run must predict the exact preimage the committed merge produces"
11467        );
11468
11469        assert!(
11470            rt.get_edge_including_deleted(&tok, edge.id.into())
11471                .await
11472                .unwrap()
11473                .is_none(),
11474            "the committed merge must actually delete the self-loop edge"
11475        );
11476    }
11477
11478    // Control: no edge exists directly between the merge operands, only one
11479    // that survives the rewire — the self-loop counter must stay at zero.
11480    #[tokio::test]
11481    async fn merge_note_no_self_loop_between_operands_reports_zero() {
11482        let rt = rt();
11483        let tok = NamespaceToken::local();
11484        let into = rt
11485            .create_note(&tok, "observation", None, "Into", None, None, vec![])
11486            .await
11487            .unwrap();
11488        let from = rt
11489            .create_note(&tok, "observation", None, "From", None, None, vec![])
11490            .await
11491            .unwrap();
11492        let other = rt
11493            .create_note(&tok, "observation", None, "Other", None, None, vec![])
11494            .await
11495            .unwrap();
11496
11497        rt.link(&tok, from.id, other.id, EdgeRelation::Supersedes, 1.0, None)
11498            .await
11499            .unwrap();
11500
11501        let summary = rt
11502            .merge_note(
11503                &tok,
11504                into.id,
11505                from.id,
11506                EntityDedupMergePolicy::PreferInto,
11507                ContentMergeStrategy::Append,
11508                false,
11509            )
11510            .await
11511            .unwrap();
11512
11513        assert_eq!(
11514            summary.edges_rewired, 1,
11515            "the non-self-loop edge must still rewire"
11516        );
11517        assert_eq!(
11518            summary.edges_self_loop_dropped, 0,
11519            "no self-loop exists between the merge operands"
11520        );
11521        assert!(summary.self_loop_edge_preimages.is_empty());
11522    }
11523
11524    // Annotates targets may be edges or events — substrates
11525    // `resolve_merge_edge_endpoint` cannot resolve. The contract check must
11526    // exempt annotates BEFORE endpoint resolution, or a note merge deletes
11527    // valid annotates edges pointing at them.
11528    #[tokio::test]
11529    async fn merge_note_preserves_annotates_edges_targeting_edges_and_events() {
11530        use khive_storage::EdgeRelation;
11531        let rt = rt();
11532        let tok = NamespaceToken::local();
11533
11534        // An edge to annotate.
11535        let a = rt
11536            .create_entity(&tok, "concept", None, "A", None, None, vec![])
11537            .await
11538            .unwrap();
11539        let b = rt
11540            .create_entity(&tok, "concept", None, "B", None, None, vec![])
11541            .await
11542            .unwrap();
11543        let annotated_edge = rt
11544            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
11545            .await
11546            .unwrap();
11547
11548        // An event to annotate: a throwaway note merge emits a NoteMerged
11549        // event (creation ops don't emit in this harness).
11550        let scrap_into = rt
11551            .create_note(&tok, "observation", None, "ScrapInto", None, None, vec![])
11552            .await
11553            .unwrap();
11554        let scrap_from = rt
11555            .create_note(&tok, "observation", None, "ScrapFrom", None, None, vec![])
11556            .await
11557            .unwrap();
11558        rt.merge_note_with_reason(
11559            &tok,
11560            scrap_into.id,
11561            scrap_from.id,
11562            EntityDedupMergePolicy::PreferInto,
11563            ContentMergeStrategy::Append,
11564            false,
11565            None,
11566        )
11567        .await
11568        .unwrap();
11569        let events = rt
11570            .events(&tok)
11571            .unwrap()
11572            .query_events(
11573                khive_storage::EventFilter {
11574                    kinds: vec![EventKind::NoteMerged],
11575                    ..Default::default()
11576                },
11577                khive_storage::types::PageRequest {
11578                    offset: 0,
11579                    limit: 1,
11580                },
11581            )
11582            .await
11583            .unwrap();
11584        let annotated_event_id = events.items[0].id;
11585
11586        let into = rt
11587            .create_note(&tok, "observation", None, "Into", None, None, vec![])
11588            .await
11589            .unwrap();
11590        let from = rt
11591            .create_note(&tok, "observation", None, "From", None, None, vec![])
11592            .await
11593            .unwrap();
11594
11595        rt.link(
11596            &tok,
11597            from.id,
11598            annotated_edge.id.0,
11599            EdgeRelation::Annotates,
11600            1.0,
11601            None,
11602        )
11603        .await
11604        .unwrap();
11605        rt.link(
11606            &tok,
11607            from.id,
11608            annotated_event_id,
11609            EdgeRelation::Annotates,
11610            1.0,
11611            None,
11612        )
11613        .await
11614        .unwrap();
11615
11616        let summary = rt
11617            .merge_note_with_reason(
11618                &tok,
11619                into.id,
11620                from.id,
11621                EntityDedupMergePolicy::PreferInto,
11622                ContentMergeStrategy::Append,
11623                false,
11624                None,
11625            )
11626            .await
11627            .unwrap();
11628
11629        assert_eq!(
11630            summary.edges_rewired, 2,
11631            "annotates edges targeting an edge and an event must be rewired"
11632        );
11633        assert_eq!(
11634            summary.edges_contract_skipped, 0,
11635            "no valid annotates edge may be dropped as contract-violating"
11636        );
11637
11638        for tgt in [annotated_edge.id.0, annotated_event_id] {
11639            let edges = rt
11640                .list_edges(
11641                    &tok,
11642                    crate::EdgeListFilter {
11643                        source_id: Some(into.id),
11644                        target_id: Some(tgt),
11645                        relations: vec![EdgeRelation::Annotates],
11646                        ..Default::default()
11647                    },
11648                    10,
11649                    0,
11650                )
11651                .await
11652                .unwrap();
11653            assert_eq!(
11654                edges.len(),
11655                1,
11656                "rewired annotates edge onto target {tgt} must survive the merge; got {edges:?}"
11657            );
11658        }
11659    }
11660
11661    // A note dry-run must predict edges_rewired like the entity path does,
11662    // and must not touch topology.
11663    #[tokio::test]
11664    async fn merge_note_dry_run_predicts_edges_rewired() {
11665        use khive_storage::EdgeRelation;
11666        let rt = rt();
11667        let tok = NamespaceToken::local();
11668        let into = rt
11669            .create_note(&tok, "observation", None, "Into", None, None, vec![])
11670            .await
11671            .unwrap();
11672        let from = rt
11673            .create_note(&tok, "observation", None, "From", None, None, vec![])
11674            .await
11675            .unwrap();
11676        let shared = rt
11677            .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
11678            .await
11679            .unwrap();
11680        rt.link(&tok, from.id, shared.id, EdgeRelation::Annotates, 1.0, None)
11681            .await
11682            .unwrap();
11683
11684        let summary = rt
11685            .merge_note_with_reason(
11686                &tok,
11687                into.id,
11688                from.id,
11689                EntityDedupMergePolicy::PreferInto,
11690                ContentMergeStrategy::Append,
11691                true,
11692                None,
11693            )
11694            .await
11695            .unwrap();
11696        assert!(summary.dry_run);
11697        assert_eq!(
11698            summary.edges_rewired, 1,
11699            "note dry-run must predict the rewire count"
11700        );
11701
11702        let from_edges = rt
11703            .list_edges(
11704                &tok,
11705                crate::EdgeListFilter {
11706                    source_id: Some(from.id),
11707                    target_id: Some(shared.id),
11708                    relations: vec![EdgeRelation::Annotates],
11709                    ..Default::default()
11710                },
11711                10,
11712                0,
11713            )
11714            .await
11715            .unwrap();
11716        assert_eq!(from_edges.len(), 1, "dry-run must leave topology untouched");
11717    }
11718
11719    #[tokio::test]
11720    async fn merge_note_different_kinds_rejected() {
11721        let rt = rt();
11722        let tok = NamespaceToken::local();
11723        let into = rt
11724            .create_note(&tok, "observation", None, "Into", None, None, vec![])
11725            .await
11726            .unwrap();
11727        let from = rt
11728            .create_note(&tok, "decision", None, "From", None, None, vec![])
11729            .await
11730            .unwrap();
11731
11732        let result = rt
11733            .merge_note_with_reason(
11734                &tok,
11735                into.id,
11736                from.id,
11737                EntityDedupMergePolicy::PreferInto,
11738                ContentMergeStrategy::Append,
11739                false,
11740                None,
11741            )
11742            .await;
11743        assert!(result.is_err(), "merging different note kinds must fail");
11744    }
11745
11746    #[tokio::test]
11747    async fn merge_note_dry_run_leaves_notes_unchanged() {
11748        let rt = rt();
11749        let tok = NamespaceToken::local();
11750        let into = rt
11751            .create_note(
11752                &tok,
11753                "observation",
11754                None,
11755                "Into content",
11756                None,
11757                None,
11758                vec![],
11759            )
11760            .await
11761            .unwrap();
11762        let from = rt
11763            .create_note(
11764                &tok,
11765                "observation",
11766                None,
11767                "From content",
11768                None,
11769                None,
11770                vec![],
11771            )
11772            .await
11773            .unwrap();
11774        let into_id = into.id;
11775        let from_id = from.id;
11776
11777        let summary = rt
11778            .merge_note_with_reason(
11779                &tok,
11780                into_id,
11781                from_id,
11782                EntityDedupMergePolicy::PreferInto,
11783                ContentMergeStrategy::Append,
11784                true,
11785                None,
11786            )
11787            .await
11788            .unwrap();
11789
11790        assert!(summary.dry_run);
11791
11792        let store = rt.notes(&tok).unwrap();
11793        let into_after = store.get_note(into_id).await.unwrap().unwrap();
11794        let from_after = store.get_note(from_id).await.unwrap().unwrap();
11795        assert_eq!(
11796            into_after.content, "Into content",
11797            "dry_run must not mutate into-note"
11798        );
11799        assert_eq!(
11800            from_after.content, "From content",
11801            "dry_run must not mutate from-note"
11802        );
11803
11804        let events = rt
11805            .events(&tok)
11806            .unwrap()
11807            .query_events(
11808                khive_storage::EventFilter {
11809                    kinds: vec![EventKind::NoteMerged],
11810                    ..Default::default()
11811                },
11812                khive_storage::types::PageRequest {
11813                    offset: 0,
11814                    limit: 10,
11815                },
11816            )
11817            .await
11818            .unwrap();
11819        assert!(
11820            events.items.is_empty(),
11821            "dry_run=true must not append a NoteMerged event"
11822        );
11823    }
11824
11825    // Merging two nameless notes with no embedding model configured: a raw SQL FTS
11826    // INSERT binding &merged_name directly would store SQL NULL for a nameless
11827    // note, while Fts5TextSearch::upsert_document stores an empty string:
11828    // note_fts_scalars must keep the round-trip field-identical.
11829    #[tokio::test]
11830    async fn merge_nameless_notes_fts_document_is_parity_correct() {
11831        use khive_storage::types::TextSearchRequest;
11832
11833        let rt = rt(); // in-memory runtime — no embedding model configured
11834        let tok = NamespaceToken::local();
11835
11836        let into = rt
11837            .create_note(
11838                &tok,
11839                "observation",
11840                None,
11841                "intosentinelzxq body",
11842                None,
11843                Some(serde_json::json!({"src": "into"})),
11844                vec![],
11845            )
11846            .await
11847            .expect("create into-note");
11848        let from = rt
11849            .create_note(
11850                &tok,
11851                "observation",
11852                None,
11853                "fromsentinelzxq body",
11854                None,
11855                None,
11856                vec![],
11857            )
11858            .await
11859            .expect("create from-note");
11860
11861        let into_id = into.id;
11862        let from_id = from.id;
11863
11864        rt.merge_note_with_reason(
11865            &tok,
11866            into_id,
11867            from_id,
11868            EntityDedupMergePolicy::PreferInto,
11869            ContentMergeStrategy::Append,
11870            false,
11871            None,
11872        )
11873        .await
11874        .expect("merge_note must succeed");
11875
11876        let note_store = rt.notes(&tok).expect("note store");
11877        let merged_note = note_store
11878            .get_note(into_id)
11879            .await
11880            .expect("get_note")
11881            .expect("merged note must exist");
11882
11883        let expected = note_fts_document(&merged_note);
11884
11885        let fts = rt.text_for_notes(&tok).expect("FTS store");
11886        let stored = fts
11887            .get_document("local", into_id)
11888            .await
11889            .expect("get_document must not error")
11890            .expect("FTS document must exist after merge");
11891
11892        assert_eq!(stored.subject_id, expected.subject_id, "subject_id");
11893        assert_eq!(
11894            stored.title, expected.title,
11895            "title (None for nameless note)"
11896        );
11897        assert_eq!(stored.body, expected.body, "body");
11898        assert_eq!(stored.namespace, expected.namespace, "namespace");
11899        assert_eq!(stored.kind, expected.kind, "kind");
11900
11901        assert!(
11902            stored.title.is_none(),
11903            "nameless merged note must have title=None in FTS (was NULL before fix)"
11904        );
11905
11906        // The merged note must be searchable by a unique token from the into-note body.
11907        let hits = fts
11908            .search(TextSearchRequest {
11909                query: "intosentinelzxq".to_string(),
11910                mode: khive_storage::types::TextQueryMode::Plain,
11911                filter: None,
11912                top_k: 10,
11913                snippet_chars: 0,
11914            })
11915            .await
11916            .expect("search");
11917        assert!(
11918            hits.iter().any(|h| h.subject_id == into_id),
11919            "merged note must be searchable by into-note content"
11920        );
11921    }
11922
11923    #[tokio::test]
11924    async fn update_edge_updates_properties() {
11925        use khive_storage::EdgeRelation;
11926        let rt = rt();
11927        let tok = NamespaceToken::local();
11928        let a = rt
11929            .create_entity(&tok, "concept", None, "A", None, None, vec![])
11930            .await
11931            .unwrap();
11932        let b = rt
11933            .create_entity(&tok, "concept", None, "B", None, None, vec![])
11934            .await
11935            .unwrap();
11936        let edge = rt
11937            .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.5, None)
11938            .await
11939            .unwrap();
11940        let edge_id: Uuid = edge.id.into();
11941
11942        let updated = rt
11943            .update_edge(
11944                &tok,
11945                edge_id,
11946                EdgePatch {
11947                    properties: Some(serde_json::json!({"source": "manual"})),
11948                    ..Default::default()
11949                },
11950            )
11951            .await
11952            .unwrap();
11953
11954        assert_eq!(updated.metadata.as_ref().unwrap()["source"], "manual");
11955        assert!((updated.weight - 0.5).abs() < 0.001, "weight unchanged");
11956    }
11957
11958    // Merge must not crash when both entities share a common third-party edge
11959    // (duplicate triple after rewire): a double-ON-CONFLICT INSERT would
11960    // otherwise raise a UNIQUE constraint error and abort mid-transaction.
11961    #[tokio::test]
11962    async fn merge_entity_survives_shared_edge_to_third_party() {
11963        use khive_storage::EdgeRelation;
11964        let rt = rt();
11965        let tok = NamespaceToken::local();
11966
11967        // A and B will be merged; shared is the common target. `extends` is used
11968        // since concept→concept is a valid endpoint combination.
11969        let a = rt
11970            .create_entity(&tok, "concept", None, "A", None, None, vec![])
11971            .await
11972            .unwrap();
11973        let b = rt
11974            .create_entity(&tok, "concept", None, "B", None, None, vec![])
11975            .await
11976            .unwrap();
11977        let shared = rt
11978            .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
11979            .await
11980            .unwrap();
11981
11982        // Both A and B extend the same shared concept — this creates a duplicate
11983        // triple (A/B → shared, extends) that triggers the crash on rewire.
11984        rt.link(&tok, a.id, shared.id, EdgeRelation::Extends, 1.0, None)
11985            .await
11986            .unwrap();
11987        rt.link(&tok, b.id, shared.id, EdgeRelation::Extends, 1.0, None)
11988            .await
11989            .unwrap();
11990
11991        let summary = rt
11992            .merge_entity_with_reason(
11993                &tok,
11994                a.id,
11995                b.id,
11996                crate::EntityDedupMergePolicy::PreferInto,
11997                ContentMergeStrategy::Append,
11998                false,
11999                None,
12000            )
12001            .await
12002            .expect(
12003                "C1: merge must succeed even when both entities share an edge to a third party",
12004            );
12005
12006        assert_eq!(summary.kept_id, a.id);
12007        assert_eq!(summary.removed_id, b.id);
12008        // A already had the Extends edge to shared; rewiring B->shared onto it
12009        // hits the natural-key conflict arm, which drops the incoming (B-side)
12010        // duplicate rather than erroring or touching A's surviving row (ADR-039
12011        // `ON CONFLICT ... DO NOTHING`). The invariant checked below is that
12012        // exactly one live edge A->shared remains.
12013        let a_edges = rt
12014            .list_edges(
12015                &tok,
12016                crate::EdgeListFilter {
12017                    source_id: Some(a.id),
12018                    target_id: Some(shared.id),
12019                    relations: vec![EdgeRelation::Extends],
12020                    ..Default::default()
12021                },
12022                10,
12023                0,
12024            )
12025            .await
12026            .unwrap();
12027        assert_eq!(
12028            a_edges.len(),
12029            1,
12030            "C1: exactly one live A→shared Extends edge must exist after merge; got: {a_edges:?}"
12031        );
12032
12033        // get_entity filters deleted_at IS NULL, so a tombstoned entity returns None.
12034        let b_after = rt.entities(&tok).unwrap().get_entity(b.id).await.unwrap();
12035        assert!(
12036            b_after.is_none(),
12037            "C3: from_entity must be tombstoned (get_entity returns None for deleted) after merge; got: {b_after:?}"
12038        );
12039    }
12040
12041    // ADR-039 conflict-arm regression (#1191): on a symmetric-edge merge collision,
12042    // the surviving row's own weight/metadata must never be overwritten with the
12043    // incoming (dropped) duplicate's values.
12044    #[tokio::test]
12045    async fn merge_entity_symmetric_conflict_preserves_survivor_fields() {
12046        use khive_storage::EdgeRelation;
12047        let rt = rt();
12048        let tok = NamespaceToken::local();
12049
12050        let a = rt
12051            .create_entity(&tok, "concept", None, "A", None, None, vec![])
12052            .await
12053            .unwrap();
12054        let b = rt
12055            .create_entity(&tok, "concept", None, "B", None, None, vec![])
12056            .await
12057            .unwrap();
12058        let shared = rt
12059            .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
12060            .await
12061            .unwrap();
12062
12063        let survivor_edge = rt
12064            .link(
12065                &tok,
12066                a.id,
12067                shared.id,
12068                EdgeRelation::Extends,
12069                1.0,
12070                Some(serde_json::json!({"source": "survivor"})),
12071            )
12072            .await
12073            .unwrap();
12074        rt.link(
12075            &tok,
12076            b.id,
12077            shared.id,
12078            EdgeRelation::Extends,
12079            0.3,
12080            Some(serde_json::json!({"source": "loser"})),
12081        )
12082        .await
12083        .unwrap();
12084
12085        rt.merge_entity_with_reason(
12086            &tok,
12087            a.id,
12088            b.id,
12089            crate::EntityDedupMergePolicy::PreferInto,
12090            ContentMergeStrategy::Append,
12091            false,
12092            None,
12093        )
12094        .await
12095        .expect("merge must succeed across the symmetric-edge collision");
12096
12097        let after = rt
12098            .get_edge(&tok, survivor_edge.id.into())
12099            .await
12100            .unwrap()
12101            .expect("survivor edge must still exist after merge");
12102        assert!(
12103            (after.weight - 1.0).abs() < 0.001,
12104            "survivor weight must be untouched by the dropped duplicate; got {}",
12105            after.weight
12106        );
12107        assert_eq!(
12108            after.metadata.as_ref().unwrap()["source"],
12109            "survivor",
12110            "survivor metadata must be untouched by the dropped duplicate; got {:?}",
12111            after.metadata
12112        );
12113    }
12114
12115    // ADR-039 conflict-arm regression (#1191): a soft-deleted survivor row must
12116    // stay soft-deleted after a merge collision, never resurrected.
12117    #[tokio::test]
12118    async fn merge_entity_symmetric_conflict_does_not_resurrect_soft_deleted_survivor() {
12119        use khive_storage::EdgeRelation;
12120        let rt = rt();
12121        let tok = NamespaceToken::local();
12122
12123        let a = rt
12124            .create_entity(&tok, "concept", None, "A", None, None, vec![])
12125            .await
12126            .unwrap();
12127        let b = rt
12128            .create_entity(&tok, "concept", None, "B", None, None, vec![])
12129            .await
12130            .unwrap();
12131        let shared = rt
12132            .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
12133            .await
12134            .unwrap();
12135
12136        let survivor_edge = rt
12137            .link(&tok, a.id, shared.id, EdgeRelation::Extends, 1.0, None)
12138            .await
12139            .unwrap();
12140        rt.delete_edge(&tok, survivor_edge.id.into(), false)
12141            .await
12142            .unwrap();
12143        rt.link(&tok, b.id, shared.id, EdgeRelation::Extends, 0.5, None)
12144            .await
12145            .unwrap();
12146
12147        rt.merge_entity_with_reason(
12148            &tok,
12149            a.id,
12150            b.id,
12151            crate::EntityDedupMergePolicy::PreferInto,
12152            ContentMergeStrategy::Append,
12153            false,
12154            None,
12155        )
12156        .await
12157        .expect("merge must succeed even when the surviving edge is soft-deleted");
12158
12159        let after = rt
12160            .get_edge_including_deleted(&tok, survivor_edge.id.into())
12161            .await
12162            .unwrap()
12163            .expect("survivor edge row must still exist after merge");
12164        assert!(
12165            after.deleted_at.is_some(),
12166            "soft-deleted survivor must stay soft-deleted after merge collision; got: {after:?}"
12167        );
12168    }
12169
12170    // merge_entity at the runtime level must reject cross-kind merges: without this
12171    // guard, a direct runtime caller could merge concept+project, silently
12172    // tombstoning the source entity, even though the pack handler also checks it.
12173    #[tokio::test]
12174    async fn merge_entity_cross_kind_rejected_at_runtime() {
12175        let rt = rt();
12176        let tok = NamespaceToken::local();
12177
12178        let concept = rt
12179            .create_entity(&tok, "concept", None, "H2Concept", None, None, vec![])
12180            .await
12181            .unwrap();
12182        let project = rt
12183            .create_entity(&tok, "project", None, "H2Project", None, None, vec![])
12184            .await
12185            .unwrap();
12186
12187        let err = rt
12188            .merge_entity_with_reason(
12189                &tok,
12190                concept.id,
12191                project.id,
12192                crate::EntityDedupMergePolicy::PreferInto,
12193                ContentMergeStrategy::Append,
12194                false,
12195                None,
12196            )
12197            .await
12198            .expect_err("H2: cross-kind merge must be rejected by runtime");
12199        assert!(
12200            matches!(err, crate::RuntimeError::InvalidInput(_)),
12201            "H2: expected InvalidInput, got: {err:?}"
12202        );
12203
12204        let concept_after = rt.get_entity(&tok, concept.id).await;
12205        let project_after = rt.get_entity(&tok, project.id).await;
12206        assert!(
12207            concept_after.is_ok(),
12208            "H2: concept must remain live after rejected merge; got: {concept_after:?}"
12209        );
12210        assert!(
12211            project_after.is_ok(),
12212            "H2: project must remain live after rejected merge; got: {project_after:?}"
12213        );
12214    }
12215
12216    // Same-kind merge must succeed.
12217    #[tokio::test]
12218    async fn merge_entity_same_kind_succeeds() {
12219        let rt = rt();
12220        let tok = NamespaceToken::local();
12221
12222        let c1 = rt
12223            .create_entity(&tok, "concept", None, "Concept1", None, None, vec![])
12224            .await
12225            .unwrap();
12226        let c2 = rt
12227            .create_entity(&tok, "concept", None, "Concept2", None, None, vec![])
12228            .await
12229            .unwrap();
12230
12231        let summary = rt
12232            .merge_entity_with_reason(
12233                &tok,
12234                c1.id,
12235                c2.id,
12236                crate::EntityDedupMergePolicy::PreferInto,
12237                ContentMergeStrategy::Append,
12238                false,
12239                None,
12240            )
12241            .await
12242            .expect("same-kind merge must succeed");
12243        assert_eq!(summary.kept_id, c1.id);
12244        assert_eq!(summary.removed_id, c2.id);
12245
12246        let c2_after = rt.entities(&tok).unwrap().get_entity(c2.id).await.unwrap();
12247        assert!(c2_after.is_none(), "from_entity must be tombstoned");
12248    }
12249
12250    #[tokio::test]
12251    async fn merge_entity_explicit_policy_rereads_names_before_commit() {
12252        let rt = rt();
12253        let tok = NamespaceToken::local();
12254
12255        let into = rt
12256            .create_entity(
12257                &tok,
12258                "concept",
12259                None,
12260                "Transactional Guard",
12261                None,
12262                None,
12263                vec![],
12264            )
12265            .await
12266            .unwrap();
12267        let from = rt
12268            .create_entity(
12269                &tok,
12270                "concept",
12271                None,
12272                "Transactional Guard",
12273                None,
12274                None,
12275                vec![],
12276            )
12277            .await
12278            .unwrap();
12279
12280        validate_entity_merge_floor(&into, &from)
12281            .expect("the handler's fast-path validation would initially pass");
12282        let renamed_into = rt
12283            .update_entity(
12284                &tok,
12285                into.id,
12286                EntityPatch {
12287                    name: Some("Unrelated Renamed Target".to_string()),
12288                    ..Default::default()
12289                },
12290            )
12291            .await
12292            .unwrap();
12293        let expected = validate_entity_merge_floor(&renamed_into, &from)
12294            .expect_err("the renamed transactional state must violate the name guard");
12295        let RuntimeError::Khive(expected) = entity_merge_guard_error(expected) else {
12296            unreachable!("merge guard errors are structured Khive errors")
12297        };
12298
12299        let err = rt
12300            .merge_entity_with_reason_and_force(
12301                &tok,
12302                into.id,
12303                from.id,
12304                EntityDedupMergePolicy::PreferInto,
12305                ContentMergeStrategy::Append,
12306                false,
12307                None,
12308                false,
12309            )
12310            .await
12311            .expect_err("the explicit non-forced path must validate its transactional reread");
12312        let RuntimeError::Khive(err) = err else {
12313            panic!("expected a structured merge-guard conflict, got {err:?}");
12314        };
12315        assert_eq!(err.kind(), expected.kind());
12316        assert_eq!(err.message(), expected.message());
12317        assert_eq!(err.code(), expected.code());
12318        assert_eq!(err.details(), expected.details());
12319        assert!(
12320            rt.get_entity(&tok, from.id).await.is_ok(),
12321            "a refused merge must leave the source entity live"
12322        );
12323    }
12324
12325    // Cross-namespace merge_note must be denied on either ID.
12326
12327    #[tokio::test]
12328    async fn merge_note_cross_namespace_either_id_returns_not_found() {
12329        use crate::error::RuntimeError;
12330        use crate::Namespace;
12331
12332        let rt = rt();
12333        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
12334        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
12335
12336        let into_a = rt
12337            .create_note(&ns_a, "observation", None, "Into A", None, None, vec![])
12338            .await
12339            .unwrap();
12340        let from_a = rt
12341            .create_note(&ns_a, "observation", None, "From A", None, None, vec![])
12342            .await
12343            .unwrap();
12344        let note_b = rt
12345            .create_note(&ns_b, "observation", None, "Note B", None, None, vec![])
12346            .await
12347            .unwrap();
12348
12349        // foreign into_id: note_b belongs to ns_b, caller token is ns_a
12350        let foreign_into = rt
12351            .merge_note_with_reason(
12352                &ns_a,
12353                note_b.id,
12354                from_a.id,
12355                EntityDedupMergePolicy::PreferInto,
12356                ContentMergeStrategy::Append,
12357                false,
12358                None,
12359            )
12360            .await;
12361        assert!(
12362            matches!(foreign_into, Err(RuntimeError::NotFound(_))),
12363            "foreign into_id must be denied before merge, got {foreign_into:?}"
12364        );
12365
12366        // foreign from_id: note_b belongs to ns_b, caller token is ns_a
12367        let foreign_from = rt
12368            .merge_note_with_reason(
12369                &ns_a,
12370                into_a.id,
12371                note_b.id,
12372                EntityDedupMergePolicy::PreferInto,
12373                ContentMergeStrategy::Append,
12374                false,
12375                None,
12376            )
12377            .await;
12378        assert!(
12379            matches!(foreign_from, Err(RuntimeError::NotFound(_))),
12380            "foreign from_id must be denied before merge, got {foreign_from:?}"
12381        );
12382    }
12383
12384    // Cross-namespace update now succeeds (shared-brain model).
12385
12386    #[tokio::test]
12387    async fn update_entity_cross_namespace_succeeds() {
12388        use crate::Namespace;
12389
12390        let rt = rt();
12391        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
12392        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
12393
12394        let entity = rt
12395            .create_entity(
12396                &ns_a,
12397                "concept",
12398                None,
12399                "Alpha",
12400                Some("original"),
12401                None,
12402                vec![],
12403            )
12404            .await
12405            .unwrap();
12406
12407        let result = rt
12408            .update_entity(
12409                &ns_b,
12410                entity.id,
12411                EntityPatch {
12412                    name: Some("Updated".into()),
12413                    ..Default::default()
12414                },
12415            )
12416            .await;
12417
12418        assert!(
12419            result.is_ok(),
12420            "cross-namespace update must succeed in shared-brain OSS; got {result:?}"
12421        );
12422        assert_eq!(result.unwrap().name, "Updated");
12423    }
12424
12425    // merge_entity still requires both entities to be in the same namespace as
12426    // the token's write namespace (enforced at the SQL transaction layer, not the
12427    // runtime layer).  This is a merge-semantic constraint, not tenant isolation.
12428    #[tokio::test]
12429    async fn merge_entity_cross_namespace_ids_fail_at_sql_layer() {
12430        use crate::Namespace;
12431
12432        let rt = rt();
12433        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
12434        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
12435
12436        let into_a = rt
12437            .create_entity(&ns_a, "concept", None, "Into A", None, None, vec![])
12438            .await
12439            .unwrap();
12440        let from_a = rt
12441            .create_entity(&ns_a, "concept", None, "From A", None, None, vec![])
12442            .await
12443            .unwrap();
12444        let foreign_b = rt
12445            .create_entity(&ns_b, "concept", None, "Foreign B", None, None, vec![])
12446            .await
12447            .unwrap();
12448
12449        // foreign into_id: SQL read_merge_entity checks ns matches token namespace.
12450        let foreign_into = rt
12451            .merge_entity_with_reason(
12452                &ns_a,
12453                foreign_b.id,
12454                from_a.id,
12455                EntityDedupMergePolicy::PreferInto,
12456                ContentMergeStrategy::Append,
12457                false,
12458                None,
12459            )
12460            .await;
12461        assert!(
12462            foreign_into.is_err(),
12463            "cross-namespace into_id must still fail at SQL layer; got {foreign_into:?}"
12464        );
12465
12466        // foreign from_id: same SQL constraint.
12467        let foreign_from = rt
12468            .merge_entity_with_reason(
12469                &ns_a,
12470                into_a.id,
12471                foreign_b.id,
12472                EntityDedupMergePolicy::PreferInto,
12473                ContentMergeStrategy::Append,
12474                false,
12475                None,
12476            )
12477            .await;
12478        assert!(
12479            foreign_from.is_err(),
12480            "cross-namespace from_id must still fail at SQL layer; got {foreign_from:?}"
12481        );
12482
12483        // All three entities survive the failed merges.
12484        assert!(rt.get_entity(&ns_a, into_a.id).await.is_ok());
12485        assert!(rt.get_entity(&ns_a, from_a.id).await.is_ok());
12486        assert!(rt.get_entity(&ns_b, foreign_b.id).await.is_ok());
12487    }
12488
12489    // Parity: entity_fts_document must produce the same body/title as the
12490    // create_entity and update_entity FTS write paths.
12491    #[test]
12492    fn entity_fts_document_with_description() {
12493        let mut entity = Entity::new("local", "concept", "MyEntity");
12494        entity = entity.with_description("some description text");
12495        let doc = entity_fts_document(&entity);
12496        assert_eq!(doc.subject_id, entity.id);
12497        assert_eq!(doc.namespace, "local");
12498        assert_eq!(doc.title.as_deref(), Some("MyEntity"));
12499        assert_eq!(doc.body, "MyEntity some description text");
12500        assert_eq!(doc.kind, khive_types::SubstrateKind::Entity);
12501    }
12502
12503    #[test]
12504    fn entity_fts_document_without_description() {
12505        let entity = Entity::new("local", "concept", "NameOnly");
12506        let doc = entity_fts_document(&entity);
12507        assert_eq!(doc.title.as_deref(), Some("NameOnly"));
12508        assert_eq!(doc.body, "NameOnly");
12509    }
12510
12511    #[test]
12512    fn entity_fts_document_empty_description_uses_name_only() {
12513        let mut entity = Entity::new("local", "concept", "TitleOnly");
12514        entity = entity.with_description("");
12515        let doc = entity_fts_document(&entity);
12516        assert_eq!(
12517            doc.body, "TitleOnly",
12518            "empty description must not be appended"
12519        );
12520    }
12521
12522    // Cross-path equality: an entity created through the runtime (operations.rs
12523    // create_entity path) must produce a stored FTS document field-identical to
12524    // entity_fts_document() called on the same Entity.
12525    #[tokio::test]
12526    async fn entity_fts_document_matches_runtime_create_path() {
12527        let rt = rt();
12528        let tok = NamespaceToken::local();
12529
12530        let entity = rt
12531            .create_entity(
12532                &tok,
12533                "concept",
12534                None,
12535                "CrossPathTitle",
12536                Some("cross path description body"),
12537                Some(serde_json::json!({"key": "val"})),
12538                vec!["tag1".to_string()],
12539            )
12540            .await
12541            .expect("create_entity");
12542
12543        let fts = rt.text(&tok).expect("FTS store");
12544        let stored = fts
12545            .get_document("local", entity.id)
12546            .await
12547            .expect("get_document")
12548            .expect("document must exist after create_entity");
12549
12550        let expected = entity_fts_document(&entity);
12551
12552        assert_eq!(stored.subject_id, expected.subject_id, "subject_id");
12553        assert_eq!(stored.kind, expected.kind, "kind");
12554        assert_eq!(stored.title, expected.title, "title");
12555        assert_eq!(stored.body, expected.body, "body");
12556        assert_eq!(stored.namespace, expected.namespace, "namespace");
12557    }
12558
12559    // Cross-path equality: update_entity must produce a stored FTS document
12560    // field-identical to entity_fts_document() on the updated Entity.
12561    #[tokio::test]
12562    async fn entity_fts_document_matches_runtime_update_path() {
12563        let rt = rt();
12564        let tok = NamespaceToken::local();
12565
12566        let entity = rt
12567            .create_entity(
12568                &tok,
12569                "concept",
12570                None,
12571                "OldName",
12572                Some("old desc"),
12573                None,
12574                vec![],
12575            )
12576            .await
12577            .expect("create_entity");
12578
12579        let updated = rt
12580            .update_entity(
12581                &tok,
12582                entity.id,
12583                EntityPatch {
12584                    name: Some("NewName".to_string()),
12585                    description: Some(Some("new desc".to_string())),
12586                    ..Default::default()
12587                },
12588            )
12589            .await
12590            .expect("update_entity");
12591
12592        let fts = rt.text(&tok).expect("FTS store");
12593        let stored = fts
12594            .get_document("local", updated.id)
12595            .await
12596            .expect("get_document")
12597            .expect("document must exist after update_entity");
12598
12599        let expected = entity_fts_document(&updated);
12600
12601        assert_eq!(stored.title, expected.title, "title after update");
12602        assert_eq!(stored.body, expected.body, "body after update");
12603    }
12604
12605    // Verify that merge_entity / merge_note delete from_id vectors from ALL
12606    // registered model vec tables, not just the default-model table. Uses the
12607    // same ConstVecProvider/ConstVecService pattern as operations.rs so no
12608    // real model files are required.
12609
12610    struct MergeTestVecService {
12611        dims: usize,
12612    }
12613
12614    #[async_trait::async_trait]
12615    impl lattice_embed::EmbeddingService for MergeTestVecService {
12616        async fn embed(
12617            &self,
12618            texts: &[String],
12619            _model: lattice_embed::EmbeddingModel,
12620        ) -> std::result::Result<Vec<Vec<f32>>, lattice_embed::EmbedError> {
12621            Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
12622        }
12623
12624        fn supports_model(&self, _model: lattice_embed::EmbeddingModel) -> bool {
12625            true
12626        }
12627
12628        fn name(&self) -> &'static str {
12629            "merge-test-const-vec"
12630        }
12631    }
12632
12633    struct MergeTestVecProvider {
12634        provider_name: String,
12635        dims: usize,
12636    }
12637
12638    impl MergeTestVecProvider {
12639        fn new(name: &str, dims: usize) -> Self {
12640            Self {
12641                provider_name: name.to_owned(),
12642                dims,
12643            }
12644        }
12645    }
12646
12647    #[async_trait::async_trait]
12648    impl crate::embedder_registry::EmbedderProvider for MergeTestVecProvider {
12649        fn name(&self) -> &str {
12650            &self.provider_name
12651        }
12652
12653        fn dimensions(&self) -> usize {
12654            self.dims
12655        }
12656
12657        async fn build(
12658            &self,
12659        ) -> crate::error::RuntimeResult<std::sync::Arc<dyn lattice_embed::EmbeddingService>>
12660        {
12661            Ok(std::sync::Arc::new(MergeTestVecService { dims: self.dims }))
12662        }
12663    }
12664
12665    async fn assert_delete_during_entity_reindex_does_not_restore_indexes(pause_vector: bool) {
12666        const MODEL: &str = "entity-reindex-delete-race";
12667        let rt = Arc::new(KhiveRuntime::memory().unwrap());
12668        let tok = NamespaceToken::local();
12669        rt.register_embedder(MergeTestVecProvider::new(MODEL, 4));
12670        let entity = rt
12671            .create_entity(
12672                &tok,
12673                "concept",
12674                None,
12675                "DeletedDuringReindex",
12676                Some("the stale document must not return"),
12677                None,
12678                vec![],
12679            )
12680            .await
12681            .unwrap();
12682        let id = entity.id;
12683        let barriers = Arc::new((tokio::sync::Barrier::new(2), tokio::sync::Barrier::new(2)));
12684        let reindex_rt = Arc::clone(&rt);
12685        let reindex_tok = tok.clone();
12686        let reindex = async move { reindex_rt.reindex_entity(&reindex_tok, &entity).await };
12687        let reindex = if pause_vector {
12688            tokio::spawn(
12689                race_seam::BEFORE_ENTITY_VECTOR_PUBLISH.scope(Arc::clone(&barriers), reindex),
12690            )
12691        } else {
12692            tokio::spawn(
12693                race_seam::BEFORE_ENTITY_INDEX_PUBLISH.scope(Arc::clone(&barriers), reindex),
12694            )
12695        };
12696
12697        tokio::time::timeout(std::time::Duration::from_secs(10), barriers.0.wait())
12698            .await
12699            .expect("reindex reached publication boundary");
12700        assert!(rt.delete_entity(&tok, id, false).await.unwrap());
12701        barriers.1.wait().await;
12702        reindex.await.unwrap().unwrap();
12703
12704        assert!(rt
12705            .text(&tok)
12706            .unwrap()
12707            .get_document("local", id)
12708            .await
12709            .unwrap()
12710            .is_none());
12711        assert_eq!(
12712            rt.vectors_for_model(&tok, MODEL)
12713                .unwrap()
12714                .count()
12715                .await
12716                .unwrap(),
12717            0
12718        );
12719    }
12720
12721    #[tokio::test]
12722    async fn deleted_entity_cannot_restore_fts_at_pending_reindex() {
12723        assert_delete_during_entity_reindex_does_not_restore_indexes(false).await;
12724    }
12725
12726    #[tokio::test]
12727    async fn deleted_entity_cannot_restore_vector_after_embedding() {
12728        assert_delete_during_entity_reindex_does_not_restore_indexes(true).await;
12729    }
12730
12731    #[tokio::test]
12732    async fn entity_reindex_with_captured_merge_plan_excludes_late_model() {
12733        const DIMS: usize = 4;
12734        const PLANNED: &str = "merge-entity-plan-existing";
12735        const LATE: &str = "merge-entity-plan-late";
12736        let rt = KhiveRuntime::memory().unwrap();
12737        let ns = crate::Namespace::parse("merge-entity-plan-snapshot").unwrap();
12738        let tok = NamespaceToken::for_namespace(ns);
12739        let entity = rt
12740            .create_entity(
12741                &tok,
12742                "concept",
12743                None,
12744                "CapturedEntityPlan",
12745                Some("full source remains indexed"),
12746                None,
12747                vec![],
12748            )
12749            .await
12750            .expect("create entity before registering embedders");
12751
12752        rt.register_embedder(MergeTestVecProvider::new(PLANNED, DIMS));
12753        let embedding_plan = EmbeddingModelPlan::capture(&rt);
12754        rt.register_embedder(MergeTestVecProvider::new(LATE, DIMS));
12755
12756        rt.reindex_entity_with_plan(&tok, &entity, &embedding_plan)
12757            .await
12758            .expect("reindex entity with captured merge plan");
12759
12760        assert_eq!(embedding_plan.model_names().len(), 1);
12761        assert_eq!(embedding_plan.model_names()[0].as_str(), PLANNED);
12762        assert_eq!(
12763            rt.vectors_for_model(&tok, PLANNED)
12764                .unwrap()
12765                .count()
12766                .await
12767                .unwrap(),
12768            1
12769        );
12770        assert_eq!(
12771            rt.vectors_for_model(&tok, LATE)
12772                .unwrap()
12773                .count()
12774                .await
12775                .unwrap(),
12776            0,
12777            "a provider registered after plan capture must not join survivor reindex"
12778        );
12779    }
12780
12781    #[tokio::test]
12782    async fn note_reindex_with_captured_merge_plan_excludes_late_model() {
12783        const DIMS: usize = 4;
12784        const PLANNED: &str = "merge-note-plan-existing";
12785        const LATE: &str = "merge-note-plan-late";
12786        let rt = KhiveRuntime::memory().unwrap();
12787        let ns = crate::Namespace::parse("merge-note-plan-snapshot").unwrap();
12788        let tok = NamespaceToken::for_namespace(ns);
12789        let note = rt
12790            .create_note(
12791                &tok,
12792                "observation",
12793                None,
12794                "full note source remains indexed",
12795                None,
12796                None,
12797                vec![],
12798            )
12799            .await
12800            .expect("create note before registering embedders");
12801
12802        rt.register_embedder(MergeTestVecProvider::new(PLANNED, DIMS));
12803        let embedding_plan = EmbeddingModelPlan::capture(&rt);
12804        rt.register_embedder(MergeTestVecProvider::new(LATE, DIMS));
12805
12806        rt.reindex_note_with_plan(&tok, &note, &embedding_plan)
12807            .await
12808            .expect("reindex note with captured merge plan");
12809
12810        assert_eq!(embedding_plan.model_names().len(), 1);
12811        assert_eq!(embedding_plan.model_names()[0].as_str(), PLANNED);
12812        assert_eq!(
12813            rt.vectors_for_model(&tok, PLANNED)
12814                .unwrap()
12815                .count()
12816                .await
12817                .unwrap(),
12818            1
12819        );
12820        assert_eq!(
12821            rt.vectors_for_model(&tok, LATE)
12822                .unwrap()
12823                .count()
12824                .await
12825                .unwrap(),
12826            0,
12827            "a provider registered after plan capture must not join survivor reindex"
12828        );
12829    }
12830
12831    /// merge_entity must delete from_id vectors from ALL registered model tables.
12832    ///
12833    /// Two custom embedders ("merge-vec-a", "merge-vec-b") are registered.  Both
12834    /// entities are embedded so each has a row in both model tables.  After merge,
12835    /// from_id must have zero surviving rows in either table.
12836    #[tokio::test]
12837    async fn merge_entity_clears_vectors_from_all_registered_models() {
12838        const DIMS: usize = 4;
12839        let rt = KhiveRuntime::memory().unwrap();
12840        rt.register_embedder(MergeTestVecProvider::new("merge-vec-a", DIMS));
12841        rt.register_embedder(MergeTestVecProvider::new("merge-vec-b", DIMS));
12842
12843        let ns_str = "merge-entity-vec-cleanup";
12844        let ns = crate::Namespace::parse(ns_str).unwrap();
12845        let tok = NamespaceToken::for_namespace(ns);
12846
12847        let into_e = rt
12848            .create_entity(
12849                &tok,
12850                "concept",
12851                None,
12852                "IntoVecEntity",
12853                Some("desc a"),
12854                None,
12855                vec![],
12856            )
12857            .await
12858            .expect("create into");
12859        let from_e = rt
12860            .create_entity(
12861                &tok,
12862                "concept",
12863                None,
12864                "FromVecEntity",
12865                Some("desc b"),
12866                None,
12867                vec![],
12868            )
12869            .await
12870            .expect("create from");
12871
12872        // Confirm both entities have vectors in both model tables before merge.
12873        let vs_a = rt.vectors_for_model(&tok, "merge-vec-a").unwrap();
12874        let vs_b = rt.vectors_for_model(&tok, "merge-vec-b").unwrap();
12875        use khive_storage::types::VectorSearchRequest;
12876        let query = vec![1.0_f32; DIMS];
12877        let pre_a = vs_a
12878            .search(VectorSearchRequest {
12879                query_vectors: vec![query.clone()],
12880                top_k: 100,
12881                namespace: Some(ns_str.to_string()),
12882                kind: Some(khive_types::SubstrateKind::Entity),
12883                embedding_model: Some("merge-vec-a".to_string()),
12884                filter: None,
12885                backend_hints: None,
12886            })
12887            .await
12888            .unwrap();
12889        assert!(
12890            pre_a.iter().any(|h| h.subject_id == into_e.id)
12891                && pre_a.iter().any(|h| h.subject_id == from_e.id),
12892            "both entities must be in model-a before merge; got {pre_a:?}"
12893        );
12894
12895        // model-b must ALSO hold both entities pre-merge, else the post-merge
12896        // model-b emptiness check below is vacuous (nothing to delete).
12897        let pre_b = vs_b
12898            .search(VectorSearchRequest {
12899                query_vectors: vec![query.clone()],
12900                top_k: 100,
12901                namespace: Some(ns_str.to_string()),
12902                kind: Some(khive_types::SubstrateKind::Entity),
12903                embedding_model: Some("merge-vec-b".to_string()),
12904                filter: None,
12905                backend_hints: None,
12906            })
12907            .await
12908            .unwrap();
12909        assert!(
12910            pre_b.iter().any(|h| h.subject_id == into_e.id)
12911                && pre_b.iter().any(|h| h.subject_id == from_e.id),
12912            "both entities must be in model-b before merge; got {pre_b:?}"
12913        );
12914
12915        rt.merge_entity_with_reason(
12916            &tok,
12917            into_e.id,
12918            from_e.id,
12919            EntityDedupMergePolicy::PreferInto,
12920            ContentMergeStrategy::Append,
12921            false,
12922            None,
12923        )
12924        .await
12925        .expect("merge_entity");
12926
12927        let post_a = vs_a
12928            .search(VectorSearchRequest {
12929                query_vectors: vec![query.clone()],
12930                top_k: 100,
12931                namespace: Some(ns_str.to_string()),
12932                kind: Some(khive_types::SubstrateKind::Entity),
12933                embedding_model: Some("merge-vec-a".to_string()),
12934                filter: None,
12935                backend_hints: None,
12936            })
12937            .await
12938            .unwrap();
12939        let from_ids_a: Vec<_> = post_a
12940            .iter()
12941            .filter(|h| h.subject_id == from_e.id)
12942            .collect();
12943        assert!(
12944            from_ids_a.is_empty(),
12945            "from_id must have no vectors in model-a after merge; got {from_ids_a:?}"
12946        );
12947
12948        let post_b = vs_b
12949            .search(VectorSearchRequest {
12950                query_vectors: vec![query],
12951                top_k: 100,
12952                namespace: Some(ns_str.to_string()),
12953                kind: Some(khive_types::SubstrateKind::Entity),
12954                embedding_model: Some("merge-vec-b".to_string()),
12955                filter: None,
12956                backend_hints: None,
12957            })
12958            .await
12959            .unwrap();
12960        let from_ids_b: Vec<_> = post_b
12961            .iter()
12962            .filter(|h| h.subject_id == from_e.id)
12963            .collect();
12964        assert!(
12965            from_ids_b.is_empty(),
12966            "from_id must have no vectors in model-b after merge; got {from_ids_b:?}"
12967        );
12968    }
12969
12970    /// merge_note must delete from_id vectors from ALL registered model tables.
12971    ///
12972    /// Two custom embedders ("merge-note-vec-a", "merge-note-vec-b") are registered.
12973    /// Both notes are embedded so each has a row in both model tables.  After merge,
12974    /// from_id must have zero surviving rows in either table.
12975    #[tokio::test]
12976    async fn merge_note_clears_vectors_from_all_registered_models() {
12977        const DIMS: usize = 4;
12978        let rt = KhiveRuntime::memory().unwrap();
12979        rt.register_embedder(MergeTestVecProvider::new("merge-note-vec-a", DIMS));
12980        rt.register_embedder(MergeTestVecProvider::new("merge-note-vec-b", DIMS));
12981
12982        let ns_str = "merge-note-vec-cleanup";
12983        let ns = crate::Namespace::parse(ns_str).unwrap();
12984        let tok = NamespaceToken::for_namespace(ns);
12985
12986        let into_n = rt
12987            .create_note(
12988                &tok,
12989                "observation",
12990                None,
12991                "IntoVecNote content",
12992                None,
12993                None,
12994                vec![],
12995            )
12996            .await
12997            .expect("create into note");
12998        let from_n = rt
12999            .create_note(
13000                &tok,
13001                "observation",
13002                None,
13003                "FromVecNote content",
13004                None,
13005                None,
13006                vec![],
13007            )
13008            .await
13009            .expect("create from note");
13010
13011        let vs_a = rt.vectors_for_model(&tok, "merge-note-vec-a").unwrap();
13012        let vs_b = rt.vectors_for_model(&tok, "merge-note-vec-b").unwrap();
13013        use khive_storage::types::VectorSearchRequest;
13014        let query = vec![1.0_f32; DIMS];
13015
13016        let pre_a = vs_a
13017            .search(VectorSearchRequest {
13018                query_vectors: vec![query.clone()],
13019                top_k: 100,
13020                namespace: Some(ns_str.to_string()),
13021                kind: Some(khive_types::SubstrateKind::Note),
13022                embedding_model: Some("merge-note-vec-a".to_string()),
13023                filter: None,
13024                backend_hints: None,
13025            })
13026            .await
13027            .unwrap();
13028        assert!(
13029            pre_a.iter().any(|h| h.subject_id == into_n.id)
13030                && pre_a.iter().any(|h| h.subject_id == from_n.id),
13031            "both notes must be in model-a before merge; got {pre_a:?}"
13032        );
13033
13034        // model-b must ALSO hold both notes pre-merge, else the post-merge
13035        // model-b emptiness check below is vacuous (nothing to delete).
13036        let pre_b = vs_b
13037            .search(VectorSearchRequest {
13038                query_vectors: vec![query.clone()],
13039                top_k: 100,
13040                namespace: Some(ns_str.to_string()),
13041                kind: Some(khive_types::SubstrateKind::Note),
13042                embedding_model: Some("merge-note-vec-b".to_string()),
13043                filter: None,
13044                backend_hints: None,
13045            })
13046            .await
13047            .unwrap();
13048        assert!(
13049            pre_b.iter().any(|h| h.subject_id == into_n.id)
13050                && pre_b.iter().any(|h| h.subject_id == from_n.id),
13051            "both notes must be in model-b before merge; got {pre_b:?}"
13052        );
13053
13054        rt.merge_note_with_reason(
13055            &tok,
13056            into_n.id,
13057            from_n.id,
13058            EntityDedupMergePolicy::PreferInto,
13059            ContentMergeStrategy::PreferInto,
13060            false,
13061            None,
13062        )
13063        .await
13064        .expect("merge_note");
13065
13066        let post_a = vs_a
13067            .search(VectorSearchRequest {
13068                query_vectors: vec![query.clone()],
13069                top_k: 100,
13070                namespace: Some(ns_str.to_string()),
13071                kind: Some(khive_types::SubstrateKind::Note),
13072                embedding_model: Some("merge-note-vec-a".to_string()),
13073                filter: None,
13074                backend_hints: None,
13075            })
13076            .await
13077            .unwrap();
13078        let from_ids_a: Vec<_> = post_a
13079            .iter()
13080            .filter(|h| h.subject_id == from_n.id)
13081            .collect();
13082        assert!(
13083            from_ids_a.is_empty(),
13084            "from_id must have no vectors in model-a after merge; got {from_ids_a:?}"
13085        );
13086
13087        let post_b = vs_b
13088            .search(VectorSearchRequest {
13089                query_vectors: vec![query],
13090                top_k: 100,
13091                namespace: Some(ns_str.to_string()),
13092                kind: Some(khive_types::SubstrateKind::Note),
13093                embedding_model: Some("merge-note-vec-b".to_string()),
13094                filter: None,
13095                backend_hints: None,
13096            })
13097            .await
13098            .unwrap();
13099        let from_ids_b: Vec<_> = post_b
13100            .iter()
13101            .filter(|h| h.subject_id == from_n.id)
13102            .collect();
13103        assert!(
13104            from_ids_b.is_empty(),
13105            "from_id must have no vectors in model-b after merge; got {from_ids_b:?}"
13106        );
13107    }
13108
13109    // Cross-path equality: merge_entity must produce a stored FTS document for
13110    // the kept entity that is field-identical to entity_fts_document().
13111    #[tokio::test]
13112    async fn entity_fts_document_matches_runtime_merge_path() {
13113        let rt = rt();
13114        let tok = NamespaceToken::local();
13115
13116        let into_e = rt
13117            .create_entity(
13118                &tok,
13119                "concept",
13120                None,
13121                "IntoEntity",
13122                Some("into desc"),
13123                None,
13124                vec![],
13125            )
13126            .await
13127            .expect("create into");
13128        let from_e = rt
13129            .create_entity(
13130                &tok,
13131                "concept",
13132                None,
13133                "FromEntity",
13134                Some("from desc"),
13135                None,
13136                vec![],
13137            )
13138            .await
13139            .expect("create from");
13140
13141        let summary = rt
13142            .merge_entity_with_reason(
13143                &tok,
13144                into_e.id,
13145                from_e.id,
13146                EntityDedupMergePolicy::PreferInto,
13147                ContentMergeStrategy::Append,
13148                false,
13149                None,
13150            )
13151            .await
13152            .expect("merge_entity");
13153
13154        let kept = rt
13155            .get_entity(&tok, summary.kept_id)
13156            .await
13157            .expect("get kept");
13158
13159        let fts = rt.text(&tok).expect("FTS store");
13160        let stored = fts
13161            .get_document("local", kept.id)
13162            .await
13163            .expect("get_document")
13164            .expect("FTS document must exist for kept entity after merge");
13165
13166        let expected = entity_fts_document(&kept);
13167
13168        assert_eq!(stored.title, expected.title, "title after merge");
13169        assert_eq!(stored.body, expected.body, "body after merge");
13170    }
13171
13172    /// The recomputed `properties_merged` count must agree with the fold on
13173    /// whole-value replacement.
13174    ///
13175    /// `merge_json` scores a `PreferFrom` replace of one properties value by a
13176    /// differently-shaped one as a single contribution. An earlier version of
13177    /// `count_new_property_keys` returned 0 for that shape, which under-reported
13178    /// every such merge — including on note kinds with no owner-established
13179    /// properties, which never enter the restoration path and were being counted
13180    /// correctly before the recompute was introduced. Measured at the time:
13181    /// fold=1, recompute=0, on both orderings.
13182    ///
13183    /// The flat-overwrite control is load-bearing: it is what distinguishes this
13184    /// test from one that a function returning 1 unconditionally would also pass.
13185    #[test]
13186    fn recomputed_count_agrees_with_fold_on_whole_value_replacement() {
13187        use serde_json::json;
13188
13189        for (into, from, label) in [
13190            (json!({"a": 1}), json!(5), "object replaced by scalar"),
13191            (
13192                json!(7),
13193                json!({"b": 2}),
13194                "scalar replaced by single-key object",
13195            ),
13196            // A whole-value replacement is ONE contribution however many keys
13197            // the replacing object carries. The single-key vector above cannot
13198            // see the difference between that rule and counting the object's
13199            // keys, so it stayed green while the count was wrong for ordinary
13200            // notes. This vector is the one that distinguishes them.
13201            (
13202                json!(7),
13203                json!({"b": 2, "c": 3}),
13204                "scalar replaced by multi-key object",
13205            ),
13206        ] {
13207            let (merged, fold_count) = merge_json(&into, &from, EntityDedupMergePolicy::PreferFrom);
13208            let recomputed = count_new_property_keys(
13209                Some(&into),
13210                Some(&merged),
13211                EntityDedupMergePolicy::PreferFrom,
13212            );
13213            assert_eq!(
13214                recomputed, fold_count,
13215                "{label}: recomputed count must match the fold's own count",
13216            );
13217            assert_eq!(recomputed, 1, "{label}: one value was contributed");
13218        }
13219
13220        // Control: an ordinary overwrite of an existing key contributes nothing
13221        // under BOTH rules. Without this arm, a function returning 1 for every
13222        // differing pair would pass the loop above.
13223        let (merged, fold_count) = merge_json(
13224            &json!({"a": 1}),
13225            &json!({"a": 2}),
13226            EntityDedupMergePolicy::PreferFrom,
13227        );
13228        let recomputed = count_new_property_keys(
13229            Some(&json!({"a": 1})),
13230            Some(&merged),
13231            EntityDedupMergePolicy::PreferFrom,
13232        );
13233        assert_eq!(fold_count, 0, "control: overwrite is never a fold addition");
13234        assert_eq!(recomputed, 0, "control: overwrite is never a new key");
13235
13236        // Control: equal values mean nothing was contributed (the `from` note
13237        // carrying no properties at all).
13238        assert_eq!(
13239            count_new_property_keys(
13240                Some(&json!({"a": 1})),
13241                Some(&json!({"a": 1})),
13242                EntityDedupMergePolicy::PreferFrom,
13243            ),
13244            0,
13245            "control: an unchanged properties object contributes nothing",
13246        );
13247    }
13248
13249    /// Recursion into a same-named nested object is only correct under `Union`.
13250    ///
13251    /// `merge_json` descends into a nested object ONLY for `Union`. Under
13252    /// `PreferFrom` an existing top-level key is replaced wholesale and under
13253    /// `PreferInto` it is kept wholesale, so nothing is merged beneath that key
13254    /// and nothing beneath it may be counted. An earlier version of the
13255    /// recomputation recursed unconditionally and reported 1 for the
13256    /// `PreferFrom` case below, where one existing property was replaced and
13257    /// none was added. This affects ordinary notes with no owner-established
13258    /// properties, which never reach the restoration path at all.
13259    #[test]
13260    fn recomputed_count_recurses_into_nested_objects_only_under_union() {
13261        use serde_json::json;
13262
13263        let into = json!({"meta": {"old": 1}});
13264        let from = json!({"meta": {"new": 2}});
13265
13266        for (strategy, expected, label) in [
13267            (
13268                EntityDedupMergePolicy::PreferFrom,
13269                0,
13270                "prefer_from replaces the whole key",
13271            ),
13272            (
13273                EntityDedupMergePolicy::PreferInto,
13274                0,
13275                "prefer_into keeps the whole key",
13276            ),
13277            (
13278                EntityDedupMergePolicy::Union,
13279                1,
13280                "union merges beneath the key",
13281            ),
13282        ] {
13283            let (merged, fold_count) = merge_json(&into, &from, strategy);
13284            let recomputed = count_new_property_keys(Some(&into), Some(&merged), strategy);
13285            assert_eq!(
13286                recomputed, fold_count,
13287                "{label}: recomputed count must match the fold's own count",
13288            );
13289            assert_eq!(recomputed, expected, "{label}");
13290        }
13291    }
13292
13293    /// An object emptied by restoration contributed nothing, and the count must
13294    /// say so.
13295    ///
13296    /// When the surviving record's properties were not an object and the fold
13297    /// installed the from-note's object, restoration removes the owner keys the
13298    /// survivor never had — which can leave `{}`. Scoring that as a whole-value
13299    /// replacement would report 1 for a record holding no properties at all.
13300    #[test]
13301    fn recomputed_count_is_zero_when_restoration_empties_the_object() {
13302        use serde_json::json;
13303
13304        assert_eq!(
13305            count_new_property_keys(
13306                Some(&json!("scalar-properties")),
13307                Some(&json!({})),
13308                EntityDedupMergePolicy::PreferFrom,
13309            ),
13310            0,
13311            "an emptied object retains nothing from the absorbed record",
13312        );
13313
13314        // Control: the same shape with a surviving key counts that key, so the
13315        // arm above is not simply returning 0 for every non-object original.
13316        assert_eq!(
13317            count_new_property_keys(
13318                Some(&json!("scalar-properties")),
13319                Some(&json!({"kept": 1})),
13320                EntityDedupMergePolicy::PreferFrom,
13321            ),
13322            1,
13323            "a surviving key is still counted",
13324        );
13325    }
13326
13327    // ---- merge transaction budget tests ----
13328
13329    /// Run `merge_entity_sql` directly on the writer connection with explicit
13330    /// limits, mapping the two-variant error the way the production fallback
13331    /// path does. The budget refusal must surface as the SQLite-side error
13332    /// whose message carries the observed counts.
13333    async fn run_entity_merge_with_limits(
13334        rt: &KhiveRuntime,
13335        into_id: Uuid,
13336        from_id: Uuid,
13337        limits: MergeTxLimits,
13338    ) -> Result<(MergeSummary, Entity), SqliteError> {
13339        let pack_rules = rt.pack_edge_rules();
13340        let pool = rt.backend().pool_arc();
13341        tokio::task::spawn_blocking(move || {
13342            let guard = pool.writer().unwrap();
13343            guard.transaction(|conn| {
13344                merge_entity_sql(
13345                    conn,
13346                    "local".to_string(),
13347                    "fts_entities".to_string(),
13348                    Vec::new(),
13349                    into_id,
13350                    from_id,
13351                    EntityDedupMergePolicy::PreferInto,
13352                    ContentMergeStrategy::Append,
13353                    false,
13354                    pack_rules,
13355                    EntityMergeValidation::LegacyKind,
13356                    limits,
13357                    Uuid::new_v4(),
13358                    None,
13359                )
13360                .map_err(|error| match error {
13361                    MergeSqlError::Sqlite(error) => error,
13362                    MergeSqlError::Refusal(_) => SqliteError::InvalidData(
13363                        "unexpected transactional policy refusal".to_string(),
13364                    ),
13365                })
13366            })
13367        })
13368        .await
13369        .unwrap()
13370    }
13371
13372    /// Preview-only variant of [`run_entity_merge_with_limits`]: runs
13373    /// `merge_entity_sql` with `dry_run = true` and an unlimited budget, and
13374    /// returns the observed byte charge without committing any write. Lets a
13375    /// test read back the probe's true cost for a record and then reuse that
13376    /// exact number to place a tight `MergeTxLimits` threshold, instead of
13377    /// guessing at fanout/overhead constants.
13378    async fn preview_entity_merge_bytes(rt: &KhiveRuntime, into_id: Uuid, from_id: Uuid) -> usize {
13379        let pack_rules = rt.pack_edge_rules();
13380        let pool = rt.backend().pool_arc();
13381        let (summary, _) = tokio::task::spawn_blocking(move || {
13382            let guard = pool.writer().unwrap();
13383            guard.transaction(|conn| {
13384                merge_entity_sql(
13385                    conn,
13386                    "local".to_string(),
13387                    "fts_entities".to_string(),
13388                    Vec::new(),
13389                    into_id,
13390                    from_id,
13391                    EntityDedupMergePolicy::PreferInto,
13392                    ContentMergeStrategy::Append,
13393                    true,
13394                    pack_rules,
13395                    EntityMergeValidation::LegacyKind,
13396                    MergeTxLimits {
13397                        max_rows: usize::MAX,
13398                        max_bytes: usize::MAX,
13399                    },
13400                    Uuid::new_v4(),
13401                    None,
13402                )
13403                .map_err(|error| match error {
13404                    MergeSqlError::Sqlite(error) => error,
13405                    MergeSqlError::Refusal(_) => SqliteError::InvalidData(
13406                        "unexpected transactional policy refusal".to_string(),
13407                    ),
13408                })
13409            })
13410        })
13411        .await
13412        .unwrap()
13413        .unwrap();
13414        summary.tx_budget.bytes_charged
13415    }
13416
13417    /// The byte-budget probe must count actual UTF-8 bytes, not SQLite's
13418    /// `LENGTH(text)` character count. Two records with an identical
13419    /// character count but different UTF-8 byte sizes (an ASCII control vs.
13420    /// a CJK payload, each 200 characters) must charge the budget
13421    /// differently — proving the probe casts to BLOB before measuring —
13422    /// and a budget threshold placed strictly between the two true costs
13423    /// must accept the ASCII control and reject the multibyte payload.
13424    #[tokio::test]
13425    async fn merge_entity_byte_budget_rejects_multibyte_properties_char_count_would_pass() {
13426        let rt = rt();
13427        let tok = NamespaceToken::local();
13428
13429        let ascii_payload = "x".repeat(200);
13430        let multibyte_payload = "\u{4e2d}".repeat(200);
13431        assert_eq!(
13432            ascii_payload.chars().count(),
13433            multibyte_payload.chars().count(),
13434            "control and payload must share one character count"
13435        );
13436        assert!(
13437            multibyte_payload.len() > ascii_payload.len(),
13438            "multibyte payload must have more UTF-8 bytes than the ASCII control"
13439        );
13440
13441        let into_ascii = rt
13442            .create_entity(&tok, "concept", None, "Into", None, None, vec![])
13443            .await
13444            .unwrap();
13445        let from_ascii = rt
13446            .create_entity(
13447                &tok,
13448                "concept",
13449                None,
13450                "From",
13451                None,
13452                Some(serde_json::json!({ "note": ascii_payload })),
13453                vec![],
13454            )
13455            .await
13456            .unwrap();
13457        let into_multi = rt
13458            .create_entity(&tok, "concept", None, "Into", None, None, vec![])
13459            .await
13460            .unwrap();
13461        let from_multi = rt
13462            .create_entity(
13463                &tok,
13464                "concept",
13465                None,
13466                "From",
13467                None,
13468                Some(serde_json::json!({ "note": multibyte_payload })),
13469                vec![],
13470            )
13471            .await
13472            .unwrap();
13473
13474        let ascii_total_bytes = preview_entity_merge_bytes(&rt, into_ascii.id, from_ascii.id).await;
13475        let multi_total_bytes = preview_entity_merge_bytes(&rt, into_multi.id, from_multi.id).await;
13476        assert!(
13477            multi_total_bytes > ascii_total_bytes,
13478            "byte-accurate probe must charge more for the multibyte record: \
13479             ascii={ascii_total_bytes} multi={multi_total_bytes}"
13480        );
13481
13482        // A threshold pinned exactly at the ASCII control's true cost must
13483        // accept it and reject the multibyte record, which a character-
13484        // counting probe would have under-charged into passing too.
13485        let limits = MergeTxLimits {
13486            max_rows: usize::MAX,
13487            max_bytes: ascii_total_bytes,
13488        };
13489
13490        run_entity_merge_with_limits(&rt, into_ascii.id, from_ascii.id, limits)
13491            .await
13492            .expect("ASCII control's true byte cost must fit its own threshold");
13493
13494        let error = run_entity_merge_with_limits(&rt, into_multi.id, from_multi.id, limits)
13495            .await
13496            .unwrap_err();
13497        let msg = error.to_string();
13498        assert!(
13499            msg.contains("merge transaction budget exceeded"),
13500            "multibyte properties must be rejected by the byte-accurate probe; got: {msg}"
13501        );
13502        assert!(msg.contains("reading merge records"), "got: {msg}");
13503        assert!(
13504            rt.get_entity(&tok, from_multi.id).await.is_ok(),
13505            "from-entity must survive a budget-rejected merge"
13506        );
13507    }
13508
13509    async fn run_note_merge_with_limits(
13510        rt: &KhiveRuntime,
13511        into_id: Uuid,
13512        from_id: Uuid,
13513        pack_rules: Vec<khive_types::EdgeEndpointRule>,
13514        limits: MergeTxLimits,
13515    ) -> Result<(MergeSummary, Note), SqliteError> {
13516        let pool = rt.backend().pool_arc();
13517        tokio::task::spawn_blocking(move || {
13518            let guard = pool.writer().unwrap();
13519            guard.transaction(|conn| {
13520                merge_note_sql(
13521                    conn,
13522                    "local".to_string(),
13523                    "fts_notes".to_string(),
13524                    Vec::new(),
13525                    into_id,
13526                    from_id,
13527                    EntityDedupMergePolicy::PreferInto,
13528                    ContentMergeStrategy::Append,
13529                    false,
13530                    pack_rules,
13531                    false,
13532                    limits,
13533                    None,
13534                )
13535                .map_err(|error| match error {
13536                    MergeSqlError::Sqlite(error) => error,
13537                    MergeSqlError::Refusal(_) => SqliteError::InvalidData(
13538                        "unexpected transactional policy refusal".to_string(),
13539                    ),
13540                })
13541            })
13542        })
13543        .await
13544        .unwrap()
13545    }
13546
13547    #[tokio::test]
13548    async fn merge_entity_rejects_row_budget_while_collecting_incident_edges() {
13549        use khive_storage::EdgeRelation;
13550        let rt = rt();
13551        let tok = NamespaceToken::local();
13552        let into = rt
13553            .create_entity(&tok, "concept", None, "Into", None, None, vec![])
13554            .await
13555            .unwrap();
13556        let from = rt
13557            .create_entity(&tok, "concept", None, "From", None, None, vec![])
13558            .await
13559            .unwrap();
13560        for name in ["T1", "T2", "T3"] {
13561            let target = rt
13562                .create_entity(&tok, "concept", None, name, None, None, vec![])
13563                .await
13564                .unwrap();
13565            rt.link(&tok, from.id, target.id, EdgeRelation::Extends, 1.0, None)
13566                .await
13567                .unwrap();
13568        }
13569
13570        // Two merge records charge first; the cap of 4 admits the first two
13571        // incident edges and trips on the third, before it is retained.
13572        let error = run_entity_merge_with_limits(
13573            &rt,
13574            into.id,
13575            from.id,
13576            MergeTxLimits {
13577                max_rows: 4,
13578                max_bytes: usize::MAX,
13579            },
13580        )
13581        .await
13582        .unwrap_err();
13583        let msg = error.to_string();
13584        assert!(
13585            msg.contains("merge transaction budget exceeded"),
13586            "got: {msg}"
13587        );
13588        assert!(msg.contains("collecting incident edges"), "got: {msg}");
13589
13590        // The rejected transaction must roll back completely.
13591        assert!(
13592            rt.get_entity(&tok, from.id).await.is_ok(),
13593            "from-entity must survive a budget-rejected merge"
13594        );
13595        let edges = rt
13596            .list_edges(
13597                &tok,
13598                EdgeListFilter {
13599                    source_id: Some(from.id),
13600                    ..Default::default()
13601                },
13602                10,
13603                0,
13604            )
13605            .await
13606            .unwrap();
13607        assert_eq!(
13608            edges.len(),
13609            3,
13610            "every incident edge must survive a budget-rejected merge"
13611        );
13612    }
13613
13614    #[tokio::test]
13615    async fn merge_entity_rejects_row_budget_while_collecting_conflict_cascade_rows() {
13616        use khive_storage::EdgeRelation;
13617        let rt = rt();
13618        let tok = NamespaceToken::local();
13619        let into = rt
13620            .create_entity(&tok, "concept", None, "Into", None, None, vec![])
13621            .await
13622            .unwrap();
13623        let from = rt
13624            .create_entity(&tok, "concept", None, "From", None, None, vec![])
13625            .await
13626            .unwrap();
13627        let shared = rt
13628            .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
13629            .await
13630            .unwrap();
13631        let annotator = rt
13632            .create_note(
13633                &tok,
13634                "observation",
13635                None,
13636                "annotator note",
13637                None,
13638                None,
13639                vec![],
13640            )
13641            .await
13642            .unwrap();
13643        let nested_annotator = rt
13644            .create_note(&tok, "observation", None, "nested note", None, None, vec![])
13645            .await
13646            .unwrap();
13647        rt.link(&tok, into.id, shared.id, EdgeRelation::Extends, 0.9, None)
13648            .await
13649            .unwrap();
13650        let dropped = rt
13651            .link(&tok, from.id, shared.id, EdgeRelation::Extends, 0.2, None)
13652            .await
13653            .unwrap();
13654        let annotation = rt
13655            .link(
13656                &tok,
13657                annotator.id,
13658                dropped.id.into(),
13659                EdgeRelation::Annotates,
13660                0.7,
13661                None,
13662            )
13663            .await
13664            .unwrap();
13665        let nested_annotation = rt
13666            .link(
13667                &tok,
13668                nested_annotator.id,
13669                annotation.id.into(),
13670                EdgeRelation::Annotates,
13671                0.6,
13672                None,
13673            )
13674            .await
13675            .unwrap();
13676
13677        // Row walk under a cap of 5: two merge records, one incident edge,
13678        // one endpoint-contract resolution, then the natural-key conflict's
13679        // recursive cascade collection charges the annotation chain and trips
13680        // on its second (nested) row.
13681        let error = run_entity_merge_with_limits(
13682            &rt,
13683            into.id,
13684            from.id,
13685            MergeTxLimits {
13686                max_rows: 5,
13687                max_bytes: usize::MAX,
13688            },
13689        )
13690        .await
13691        .unwrap_err();
13692        let msg = error.to_string();
13693        assert!(
13694            msg.contains("merge transaction budget exceeded"),
13695            "got: {msg}"
13696        );
13697        assert!(
13698            msg.contains("collecting conflict cascade rows"),
13699            "got: {msg}"
13700        );
13701
13702        // Roll back means the whole annotation chain is still present.
13703        for id in [dropped.id, annotation.id, nested_annotation.id] {
13704            assert!(
13705                rt.get_edge_including_deleted(&tok, id.into())
13706                    .await
13707                    .unwrap()
13708                    .is_some(),
13709                "edge {id} must survive a budget-rejected merge"
13710            );
13711        }
13712        assert!(
13713            rt.get_entity(&tok, from.id).await.is_ok(),
13714            "from-entity must survive a budget-rejected merge"
13715        );
13716    }
13717
13718    #[tokio::test]
13719    async fn merge_note_rejects_byte_budget_while_reading_merge_records() {
13720        let rt = rt();
13721        let tok = NamespaceToken::local();
13722        let into = rt
13723            .create_note(
13724                &tok,
13725                "observation",
13726                None,
13727                "into content",
13728                None,
13729                None,
13730                vec![],
13731            )
13732            .await
13733            .unwrap();
13734        let fat = "x".repeat(8192);
13735        let from = rt
13736            .create_note(&tok, "observation", None, &fat, None, None, vec![])
13737            .await
13738            .unwrap();
13739
13740        let error = run_note_merge_with_limits(
13741            &rt,
13742            into.id,
13743            from.id,
13744            Vec::new(),
13745            MergeTxLimits {
13746                max_rows: usize::MAX,
13747                max_bytes: 4096,
13748            },
13749        )
13750        .await
13751        .unwrap_err();
13752        let msg = error.to_string();
13753        assert!(
13754            msg.contains("merge transaction budget exceeded"),
13755            "got: {msg}"
13756        );
13757        assert!(msg.contains("reading merge records"), "got: {msg}");
13758
13759        assert!(
13760            rt.notes(&tok)
13761                .unwrap()
13762                .get_note(from.id)
13763                .await
13764                .unwrap()
13765                .is_some(),
13766            "from-note must survive a budget-rejected merge"
13767        );
13768    }
13769
13770    /// The byte budget must be charged from a cheap SQL-side length probe
13771    /// BEFORE the merge fully loads and JSON-parses a record's `properties`
13772    /// column — never after. Prove it adversarially: store an oversized
13773    /// `properties` value that is also invalid JSON directly on `from`,
13774    /// bypassing the create path's own validation. If the budget were still
13775    /// charged only after `read_merge_entity`'s full load-and-parse (the
13776    /// pre-fix ordering), this merge would fail with a JSON parse error
13777    /// instead of a budget error, because the parse would run before the
13778    /// stale post-read charge was ever reached. Charging from the pre-parse
13779    /// length probe must reject on budget first, so `serde_json::from_str`
13780    /// never runs on this column at all.
13781    #[tokio::test]
13782    async fn merge_entity_rejects_byte_budget_before_parsing_oversized_malformed_properties() {
13783        let rt = rt();
13784        let tok = NamespaceToken::local();
13785        let into = rt
13786            .create_entity(&tok, "concept", None, "Into", None, None, vec![])
13787            .await
13788            .unwrap();
13789        let from = rt
13790            .create_entity(&tok, "concept", None, "From", None, None, vec![])
13791            .await
13792            .unwrap();
13793
13794        let huge_malformed_properties = format!("{{not valid json: {}", "x".repeat(8192));
13795        let pool = rt.backend().pool_arc();
13796        let from_id = from.id;
13797        tokio::task::spawn_blocking(move || {
13798            let guard = pool.writer().unwrap();
13799            guard.transaction(|conn| {
13800                conn.execute(
13801                    "UPDATE entities SET version = version + 1, properties = ?1 WHERE id = ?2",
13802                    rusqlite::params![huge_malformed_properties, from_id.to_string()],
13803                )?;
13804                Ok(())
13805            })
13806        })
13807        .await
13808        .unwrap()
13809        .unwrap();
13810
13811        let error = run_entity_merge_with_limits(
13812            &rt,
13813            into.id,
13814            from.id,
13815            MergeTxLimits {
13816                max_rows: usize::MAX,
13817                max_bytes: 4096,
13818            },
13819        )
13820        .await
13821        .unwrap_err();
13822        let msg = error.to_string();
13823        assert!(
13824            msg.contains("merge transaction budget exceeded"),
13825            "expected an early budget rejection, not a JSON parse failure; got: {msg}"
13826        );
13827        assert!(msg.contains("reading merge records"), "got: {msg}");
13828
13829        // `get_entity` would itself fail to parse the malformed properties this
13830        // test deliberately stored, so check survival via a raw row count
13831        // instead of the parsing read path.
13832        let pool = rt.backend().pool_arc();
13833        let still_present: i64 = tokio::task::spawn_blocking(move || {
13834            let guard = pool.writer().unwrap();
13835            guard.transaction(|conn| {
13836                conn.query_row(
13837                    "SELECT COUNT(*) FROM entities WHERE id = ?1 AND deleted_at IS NULL",
13838                    rusqlite::params![from_id.to_string()],
13839                    |row| row.get(0),
13840                )
13841                .map_err(SqliteError::Rusqlite)
13842            })
13843        })
13844        .await
13845        .unwrap()
13846        .unwrap();
13847        assert_eq!(
13848            still_present, 1,
13849            "from-entity must survive a budget-rejected merge"
13850        );
13851    }
13852
13853    #[tokio::test]
13854    async fn merge_note_rejects_row_budget_while_collecting_incident_edges() {
13855        use khive_storage::EdgeRelation;
13856        let rt = rt();
13857        let tok = NamespaceToken::local();
13858        let into = rt
13859            .create_note(&tok, "observation", None, "Into", None, None, vec![])
13860            .await
13861            .unwrap();
13862        let from = rt
13863            .create_note(&tok, "observation", None, "From", None, None, vec![])
13864            .await
13865            .unwrap();
13866        for name in ["T1", "T2", "T3"] {
13867            let target = rt
13868                .create_entity(&tok, "concept", None, name, None, None, vec![])
13869                .await
13870                .unwrap();
13871            rt.link(&tok, from.id, target.id, EdgeRelation::Annotates, 1.0, None)
13872                .await
13873                .unwrap();
13874        }
13875
13876        let error = run_note_merge_with_limits(
13877            &rt,
13878            into.id,
13879            from.id,
13880            rt.pack_edge_rules(),
13881            MergeTxLimits {
13882                max_rows: 4,
13883                max_bytes: usize::MAX,
13884            },
13885        )
13886        .await
13887        .unwrap_err();
13888        let msg = error.to_string();
13889        assert!(
13890            msg.contains("merge transaction budget exceeded"),
13891            "got: {msg}"
13892        );
13893        assert!(msg.contains("collecting incident edges"), "got: {msg}");
13894
13895        let edges = rt
13896            .list_edges(
13897                &tok,
13898                EdgeListFilter {
13899                    source_id: Some(from.id),
13900                    ..Default::default()
13901                },
13902                10,
13903                0,
13904            )
13905            .await
13906            .unwrap();
13907        assert_eq!(
13908            edges.len(),
13909            3,
13910            "every incident edge must survive a budget-rejected merge"
13911        );
13912    }
13913
13914    // The post-commit budget logs are captured by the process-global tracing
13915    // subscriber owned by `crate::pack::tests` — one test binary supports at
13916    // most one `set_global_default`, and a thread-local `set_default` guard
13917    // here proved lossy under parallel tests (the same event-loss class the
13918    // pack tests' subscriber documents). Each test selects its own rows from
13919    // the append-only sink by the merge's `into_id`.
13920    use crate::pack::tests::budget_log_events;
13921
13922    #[tokio::test]
13923    async fn merge_entity_reports_and_logs_tx_budget_after_commit() {
13924        use khive_storage::EdgeRelation;
13925        let events = budget_log_events();
13926
13927        let rt = rt();
13928        let tok = NamespaceToken::local();
13929        let into = rt
13930            .create_entity(&tok, "concept", None, "Into", None, None, vec![])
13931            .await
13932            .unwrap();
13933        let from = rt
13934            .create_entity(&tok, "concept", None, "From", None, None, vec![])
13935            .await
13936            .unwrap();
13937        let target = rt
13938            .create_entity(&tok, "concept", None, "Target", None, None, vec![])
13939            .await
13940            .unwrap();
13941        rt.link(&tok, from.id, target.id, EdgeRelation::Extends, 1.0, None)
13942            .await
13943            .unwrap();
13944
13945        // A dry run reports the same predictive budget usage but must not
13946        // emit the post-commit log: nothing committed.
13947        let preview = rt
13948            .merge_entity(
13949                &tok,
13950                into.id,
13951                from.id,
13952                EntityDedupMergePolicy::PreferInto,
13953                ContentMergeStrategy::Append,
13954                true,
13955            )
13956            .await
13957            .unwrap();
13958        assert!(preview.tx_budget.rows_charged >= 2);
13959        assert_eq!(preview.tx_budget.max_rows, MERGE_TX_MAX_ROWS);
13960        assert_eq!(preview.tx_budget.max_bytes, MERGE_TX_MAX_BYTES);
13961        assert!(
13962            events
13963                .lock()
13964                .unwrap()
13965                .iter()
13966                .all(|e| e.into_id != into.id.to_string()),
13967            "a dry-run preview must not emit the post-commit budget log"
13968        );
13969
13970        let summary = rt
13971            .merge_entity(
13972                &tok,
13973                into.id,
13974                from.id,
13975                EntityDedupMergePolicy::PreferInto,
13976                ContentMergeStrategy::Append,
13977                false,
13978            )
13979            .await
13980            .unwrap();
13981        assert!(summary.tx_budget.rows_charged >= 2);
13982        assert!(summary.tx_budget.bytes_charged > 0);
13983
13984        let captured = events.lock().unwrap();
13985        let row = captured
13986            .iter()
13987            .find(|e| {
13988                e.into_id == summary.kept_id.to_string()
13989                    && e.message == "merge_entity: transaction materialization budget"
13990            })
13991            .expect("committing entity merge must emit the post-commit budget log");
13992        assert_eq!(
13993            row.budget_rows as usize, summary.tx_budget.rows_charged,
13994            "the log must carry the same observed row count the summary reports"
13995        );
13996    }
13997
13998    #[tokio::test]
13999    async fn merge_note_reports_and_logs_tx_budget_after_commit() {
14000        let events = budget_log_events();
14001
14002        let rt = rt();
14003        let tok = NamespaceToken::local();
14004        let into = rt
14005            .create_note(&tok, "observation", None, "Into", None, None, vec![])
14006            .await
14007            .unwrap();
14008        let from = rt
14009            .create_note(&tok, "observation", None, "From", None, None, vec![])
14010            .await
14011            .unwrap();
14012
14013        let summary = rt
14014            .merge_note(
14015                &tok,
14016                into.id,
14017                from.id,
14018                EntityDedupMergePolicy::PreferInto,
14019                ContentMergeStrategy::Append,
14020                false,
14021            )
14022            .await
14023            .unwrap();
14024        assert!(summary.tx_budget.rows_charged >= 2);
14025        assert!(summary.tx_budget.bytes_charged > 0);
14026
14027        let captured = events.lock().unwrap();
14028        let row = captured
14029            .iter()
14030            .find(|e| {
14031                e.into_id == summary.kept_id.to_string()
14032                    && e.message == "merge_note: transaction materialization budget"
14033            })
14034            .expect("committing note merge must emit the post-commit budget log");
14035        assert_eq!(
14036            row.budget_rows as usize, summary.tx_budget.rows_charged,
14037            "the log must carry the same observed row count the summary reports"
14038        );
14039    }
14040
14041    // ── Universal reserved-key reservation (ADR-115 Amendment 1, first rung) ──
14042
14043    fn reserved_key_props() -> serde_json::Value {
14044        serde_json::json!({"khive:secret_gate": "exempted:content-sha256-manifest-v1"})
14045    }
14046
14047    #[tokio::test]
14048    async fn update_entity_rejects_reserved_secret_gate_key() {
14049        let rt = rt();
14050        let tok = NamespaceToken::local();
14051        let entity = rt
14052            .create_entity(
14053                &tok,
14054                "concept",
14055                None,
14056                "reservation-target-entity",
14057                None,
14058                Some(serde_json::json!({"k": "v"})),
14059                vec![],
14060            )
14061            .await
14062            .unwrap();
14063
14064        let err = rt
14065            .update_entity(
14066                &tok,
14067                entity.id,
14068                EntityPatch {
14069                    properties: Some(reserved_key_props()),
14070                    ..Default::default()
14071                },
14072            )
14073            .await
14074            .expect_err("caller-supplied reserved key must be rejected on patch update");
14075        assert!(
14076            matches!(err, RuntimeError::InvalidInput(ref msg) if msg.contains("khive:secret_gate")),
14077            "unexpected error: {err:?}"
14078        );
14079
14080        // No partial mutation: the original properties must be unchanged.
14081        let unchanged = rt
14082            .entities(&tok)
14083            .unwrap()
14084            .get_entity(entity.id)
14085            .await
14086            .unwrap()
14087            .unwrap();
14088        assert_eq!(unchanged.properties, Some(serde_json::json!({"k": "v"})));
14089    }
14090
14091    #[tokio::test]
14092    async fn update_note_rejects_reserved_secret_gate_key() {
14093        let rt = rt();
14094        let tok = NamespaceToken::local();
14095        let note = rt
14096            .create_note(
14097                &tok,
14098                "observation",
14099                None,
14100                "reservation target note",
14101                None,
14102                Some(serde_json::json!({"k": "v"})),
14103                vec![],
14104            )
14105            .await
14106            .unwrap();
14107
14108        let err = rt
14109            .update_note(
14110                &tok,
14111                note.id,
14112                NotePatch::new(None, None, None, None, Some(reserved_key_props())),
14113            )
14114            .await
14115            .expect_err("caller-supplied reserved key must be rejected on patch update");
14116        assert!(
14117            matches!(err, RuntimeError::InvalidInput(ref msg) if msg.contains("khive:secret_gate")),
14118            "unexpected error: {err:?}"
14119        );
14120
14121        let unchanged = rt
14122            .notes(&tok)
14123            .unwrap()
14124            .get_note(note.id)
14125            .await
14126            .unwrap()
14127            .unwrap();
14128        assert_eq!(unchanged.properties, Some(serde_json::json!({"k": "v"})));
14129    }
14130
14131    // -----------------------------------------------------------------
14132    // #2943: prepare_guarded_entity_update dispatches an installed
14133    // entity-kind KindHook against the post-merge properties.
14134    // -----------------------------------------------------------------
14135
14136    /// Test-only `KindHook` whose `validate_entity_update` refuses unless
14137    /// `properties.ok == true`. Proves `prepare_guarded_entity_update`
14138    /// actually dispatches to whichever hook `entity_kind_hook` resolves
14139    /// for the entity's kind, and that the properties it sees are the
14140    /// MERGED (post-patch) value rather than the caller's raw patch.
14141    ///
14142    /// Mutation prediction: removing the dispatch call this test exercises
14143    /// (the `if let Some(hook) = self.entity_kind_hook(...)` block added at
14144    /// the seam) makes `entity_update_dispatches_installed_kind_hook_refusal`
14145    /// fail — the refusing hook never runs, so the update that should be
14146    /// refused instead succeeds and `expect_err` panics.
14147    #[derive(Debug, Default)]
14148    struct RefusingKindHook;
14149
14150    #[async_trait::async_trait]
14151    impl crate::pack::KindHook for RefusingKindHook {
14152        async fn prepare_create(
14153            &self,
14154            _runtime: &KhiveRuntime,
14155            _args: &mut Value,
14156        ) -> Result<(), RuntimeError> {
14157            Ok(())
14158        }
14159
14160        async fn after_create(
14161            &self,
14162            _runtime: &KhiveRuntime,
14163            _id: Uuid,
14164            _args: &Value,
14165        ) -> Result<(), RuntimeError> {
14166            Ok(())
14167        }
14168
14169        async fn validate_entity_update(
14170            &self,
14171            _runtime: &KhiveRuntime,
14172            _token: &NamespaceToken,
14173            _entity: &Entity,
14174            properties: Option<&Value>,
14175        ) -> Result<(), RuntimeError> {
14176            let ok = properties
14177                .and_then(Value::as_object)
14178                .and_then(|p| p.get("ok"))
14179                .and_then(Value::as_bool)
14180                .unwrap_or(false);
14181            if ok {
14182                Ok(())
14183            } else {
14184                Err(RuntimeError::InvalidInput(
14185                    "widget update requires properties.ok == true".into(),
14186                ))
14187            }
14188        }
14189    }
14190
14191    /// Test-only `KindHook` implementing only the two required methods, so
14192    /// its entity-update path is the trait's inherited default. Proves the
14193    /// default is a default (issue #2943 acceptance item 5): a kind can
14194    /// register a hook for `create` without that hook opting into
14195    /// update-time validation, and a generic entity `update` must still
14196    /// succeed.
14197    ///
14198    /// Mutation prediction: if the trait default stopped returning `Ok(())`
14199    /// (e.g. it were changed to re-run `prepare_create`-shaped logic),
14200    /// `entity_update_with_hook_missing_the_default_method_still_succeeds`
14201    /// fails, because this hook has nothing else to satisfy any such check.
14202    #[derive(Debug, Default)]
14203    struct SilentKindHook;
14204
14205    #[async_trait::async_trait]
14206    impl crate::pack::KindHook for SilentKindHook {
14207        async fn prepare_create(
14208            &self,
14209            _runtime: &KhiveRuntime,
14210            _args: &mut Value,
14211        ) -> Result<(), RuntimeError> {
14212            Ok(())
14213        }
14214
14215        async fn after_create(
14216            &self,
14217            _runtime: &KhiveRuntime,
14218            _id: Uuid,
14219            _args: &Value,
14220        ) -> Result<(), RuntimeError> {
14221            Ok(())
14222        }
14223    }
14224
14225    #[tokio::test]
14226    async fn entity_update_dispatches_installed_kind_hook_refusal() {
14227        let rt = rt();
14228        rt.install_entity_kind_hooks(vec![(
14229            "widget".to_string(),
14230            Arc::new(RefusingKindHook) as Arc<dyn crate::pack::KindHook>,
14231        )]);
14232        let tok = NamespaceToken::local();
14233        let entity = rt
14234            .create_entity(
14235                &tok,
14236                "widget",
14237                None,
14238                "Gadget",
14239                None,
14240                Some(serde_json::json!({"ok": true})),
14241                vec![],
14242            )
14243            .await
14244            .unwrap();
14245
14246        let error = rt
14247            .update_entity(
14248                &tok,
14249                entity.id,
14250                EntityPatch {
14251                    properties: Some(serde_json::json!({"ok": false})),
14252                    ..Default::default()
14253                },
14254            )
14255            .await
14256            .expect_err("installed hook must refuse the merged properties");
14257        assert!(
14258            matches!(error, RuntimeError::InvalidInput(ref msg) if msg.contains("requires properties.ok")),
14259            "unexpected error: {error:?}"
14260        );
14261        let unchanged = rt.get_entity(&tok, entity.id).await.unwrap();
14262        assert_eq!(
14263            unchanged.properties, entity.properties,
14264            "a refused update must not mutate storage"
14265        );
14266    }
14267
14268    #[tokio::test]
14269    async fn entity_update_dispatches_installed_kind_hook_acceptance() {
14270        let rt = rt();
14271        rt.install_entity_kind_hooks(vec![(
14272            "widget".to_string(),
14273            Arc::new(RefusingKindHook) as Arc<dyn crate::pack::KindHook>,
14274        )]);
14275        let tok = NamespaceToken::local();
14276        let entity = rt
14277            .create_entity(
14278                &tok,
14279                "widget",
14280                None,
14281                "Gadget",
14282                None,
14283                Some(serde_json::json!({"ok": false})),
14284                vec![],
14285            )
14286            .await
14287            .unwrap();
14288
14289        let updated = rt
14290            .update_entity(
14291                &tok,
14292                entity.id,
14293                EntityPatch {
14294                    properties: Some(serde_json::json!({"ok": true})),
14295                    ..Default::default()
14296                },
14297            )
14298            .await
14299            .expect("installed hook accepts a merged properties value satisfying its check");
14300        assert_eq!(updated.properties, Some(serde_json::json!({"ok": true})));
14301    }
14302
14303    #[tokio::test]
14304    async fn entity_update_with_hook_missing_the_default_method_still_succeeds() {
14305        let rt = rt();
14306        rt.install_entity_kind_hooks(vec![(
14307            "widget".to_string(),
14308            Arc::new(SilentKindHook) as Arc<dyn crate::pack::KindHook>,
14309        )]);
14310        let tok = NamespaceToken::local();
14311        let entity = rt
14312            .create_entity(&tok, "widget", None, "Gadget", None, None, vec![])
14313            .await
14314            .unwrap();
14315
14316        let updated = rt
14317            .update_entity(
14318                &tok,
14319                entity.id,
14320                EntityPatch {
14321                    name: Some("Renamed Gadget".to_string()),
14322                    ..Default::default()
14323                },
14324            )
14325            .await
14326            .expect(
14327                "a hook that does not override validate_entity_update must not block the update",
14328            );
14329        assert_eq!(updated.name, "Renamed Gadget");
14330    }
14331
14332    /// A kind with no installed hook at all (the pre-#2943 behaviour) must
14333    /// still update freely — `entity_kind_hook` returns `None` and the
14334    /// dispatch site's `if let Some(hook) = ...` is skipped entirely.
14335    #[tokio::test]
14336    async fn entity_update_with_no_installed_hook_for_kind_succeeds() {
14337        let rt = rt();
14338        let tok = NamespaceToken::local();
14339        let entity = rt
14340            .create_entity(&tok, "concept", None, "Plain", None, None, vec![])
14341            .await
14342            .unwrap();
14343
14344        let updated = rt
14345            .update_entity(
14346                &tok,
14347                entity.id,
14348                EntityPatch {
14349                    name: Some("Plain Renamed".to_string()),
14350                    ..Default::default()
14351                },
14352            )
14353            .await
14354            .expect("no hook installed for this kind must not block the update");
14355        assert_eq!(updated.name, "Plain Renamed");
14356    }
14357}