Skip to main content

khive_runtime/curation/
note_merge.rs

1//! Asynchronous note merge, with or without a caller-supplied guard.
2
3use super::*;
4
5impl KhiveRuntime {
6    /// Merge `from_id` note into `into_id` note.
7    ///
8    /// Both notes must exist in the namespace and have the same `kind`. Content is merged
9    /// per `content_strategy`. Properties are merged per `strategy`. `from_id` is
10    /// tombstoned (status='deleted', deleted_at set). Returns a summary.
11    ///
12    /// If `dry_run` is true, computes and returns the planned summary without mutating
13    /// any rows, edges, or indexes.
14    /// The NoteMerged event, including destructive edge preimages, commits in
15    /// the same SQL transaction as the note and edge changes.
16    pub async fn merge_note(
17        &self,
18        token: &NamespaceToken,
19        into_id: Uuid,
20        from_id: Uuid,
21        strategy: EntityDedupMergePolicy,
22        content_strategy: ContentMergeStrategy,
23        dry_run: bool,
24    ) -> RuntimeResult<MergeSummary> {
25        self.merge_note_with_reason(
26            token,
27            into_id,
28            from_id,
29            strategy,
30            content_strategy,
31            dry_run,
32            None,
33        )
34        .await
35    }
36
37    /// Merge `from_id` note into `into_id` note and include an optional audit reason.
38    // REASON: these arguments mirror the merge verb's policy, content strategy,
39    // dry-run, and audit-reason fields; a builder would only move that surface.
40    #[allow(clippy::too_many_arguments)]
41    pub async fn merge_note_with_reason(
42        &self,
43        token: &NamespaceToken,
44        into_id: Uuid,
45        from_id: Uuid,
46        strategy: EntityDedupMergePolicy,
47        content_strategy: ContentMergeStrategy,
48        dry_run: bool,
49        reason: Option<String>,
50    ) -> RuntimeResult<MergeSummary> {
51        self.merge_note_with_guard(
52            token,
53            into_id,
54            from_id,
55            strategy,
56            content_strategy,
57            dry_run,
58            reason,
59            None,
60        )
61        .await
62        .map(|(summary, _)| summary)
63    }
64
65    /// Merge `from_id` note into `into_id` note, refusing unless the caller's
66    /// guard still holds.
67    ///
68    /// The guard names the version of each note the caller read, facts it relied
69    /// on (see [`MergeAssertion`]), property values that replace the survivor's,
70    /// and one annotation for the provenance entry. All of it is checked inside
71    /// the merge transaction, on the writer connection, after both notes are read
72    /// there and before the first write; a refusal writes nothing. The returned
73    /// `kept_version` is the survivor's committed version, so the caller can
74    /// merge the next duplicate into the same survivor without reading it again.
75    ///
76    /// With no guard this is [`Self::merge_note_with_reason`]: the same
77    /// statements in the same order, with the same errors and effects.
78    // REASON: these arguments mirror the merge verb's policy, content strategy,
79    // dry-run, and audit-reason fields plus the guard; a builder would only move
80    // that surface.
81    #[allow(clippy::too_many_arguments)]
82    pub async fn merge_note_guarded(
83        &self,
84        token: &NamespaceToken,
85        into_id: Uuid,
86        from_id: Uuid,
87        strategy: EntityDedupMergePolicy,
88        content_strategy: ContentMergeStrategy,
89        dry_run: bool,
90        reason: Option<String>,
91        guard: NoteMergeGuard,
92    ) -> RuntimeResult<GuardedNoteMerge> {
93        let (summary, kept_version) = self
94            .merge_note_with_guard(
95                token,
96                into_id,
97                from_id,
98                strategy,
99                content_strategy,
100                dry_run,
101                reason,
102                Some(guard),
103            )
104            .await?;
105        Ok(GuardedNoteMerge {
106            summary,
107            kept_version,
108        })
109    }
110
111    /// Merge `from_id` note into `into_id` note and include an optional audit reason.
112    // REASON: these arguments mirror the merge verb's policy, content strategy,
113    // dry-run, and audit-reason fields; a builder would only move that surface.
114    #[allow(clippy::too_many_arguments)]
115    pub(super) async fn merge_note_with_guard(
116        &self,
117        token: &NamespaceToken,
118        into_id: Uuid,
119        from_id: Uuid,
120        strategy: EntityDedupMergePolicy,
121        content_strategy: ContentMergeStrategy,
122        dry_run: bool,
123        reason: Option<String>,
124        merge_guard: Option<NoteMergeGuard>,
125    ) -> RuntimeResult<(MergeSummary, i64)> {
126        if let Some(reason) = reason.as_deref() {
127            crate::secret_gate::check_at(reason, "merge", "reason")?;
128        }
129        if let Some(merge_guard) = merge_guard.as_ref() {
130            merge_guard.check_values()?;
131        }
132        if into_id == from_id {
133            return Err(RuntimeError::InvalidInput(
134                "cannot merge a note into itself".into(),
135            ));
136        }
137        let ns = token.namespace().as_str().to_string();
138        let fts_table = "fts_notes".to_string();
139        // Keep deletion, table preparation, and survivor reindex on the same
140        // immutable registry view; see the entity merge path above.
141        let embedding_plan = EmbeddingModelPlan::capture(self);
142        let vec_tables = embedding_plan.vector_tables();
143        let pack_rules = self.pack_edge_rules();
144
145        let note_store = self.notes(token)?;
146        let into_note = note_store
147            .get_note(into_id)
148            .await?
149            .ok_or_else(|| RuntimeError::NotFound("not found in this namespace".into()))?;
150        Self::ensure_namespace(&into_note.namespace, &ns)?;
151
152        let from_note = note_store
153            .get_note(from_id)
154            .await?
155            .ok_or_else(|| RuntimeError::NotFound("not found in this namespace".into()))?;
156        Self::ensure_namespace(&from_note.namespace, &ns)?;
157
158        if !dry_run {
159            for note in [&into_note, &from_note] {
160                if let Some(error) = self.stream_member_error(note).await? {
161                    return Err(error);
162                }
163            }
164        }
165        reject_pack_managed_schedule_mutation(&into_note, "merge")?;
166        reject_pack_managed_schedule_mutation(&from_note, "merge")?;
167
168        let _ = self.graph(token)?;
169        let _ = self.text_for_notes(token)?;
170        let _ = self.events(token)?;
171        for model_name in embedding_plan.model_names() {
172            let _ = self.vectors_for_model(token, model_name)?;
173        }
174
175        // Resolved here, where the runtime's installed pack-kind list is in
176        // reach; `merge_note_sql` runs on the writer connection with no runtime
177        // handle. Both notes share a kind (checked inside), so the into-note's
178        // kind decides for the merge.
179        let preserve_owner_established = self.is_pack_owned_note_kind(&into_note.kind);
180
181        let pool = self.backend().pool_arc();
182        let writer_task = pool
183            .writer_task_for_runtime_write(RuntimeWriteOperation::MergeNote)
184            .map_err(RuntimeError::Storage)?;
185        let event_context = MergeEventContext {
186            attribution: EventAttribution::from_token(token),
187            reason,
188            force: false,
189            strategy,
190            content_strategy,
191            kind: EventKind::NoteMerged,
192            substrate: SubstrateKind::Note,
193            event_id: None,
194        };
195
196        let (mut summary, updated_note) = if let Some(writer_task) = writer_task {
197            writer_task
198                .send(move |conn| {
199                    merge_note_sql(
200                        conn,
201                        ns,
202                        fts_table,
203                        vec_tables,
204                        into_id,
205                        from_id,
206                        strategy,
207                        content_strategy,
208                        dry_run,
209                        pack_rules,
210                        preserve_owner_established,
211                        MergeTxLimits::default(),
212                        Some(event_context),
213                        merge_guard,
214                    )
215                    .map_err(|e| {
216                        e.into_storage_error(khive_storage::StorageCapability::Notes, "merge_note")
217                    })
218                })
219                .await
220                .inspect_err(|error| khive_storage::usage::account_event_write(Err(error)))
221                .map_err(map_merge_note_storage_error)?
222        } else {
223            tokio::task::spawn_blocking(move || {
224                let guard = pool.writer()?;
225                let mut refusal = None;
226                let result = guard.transaction(|conn| {
227                    merge_note_sql(
228                        conn,
229                        ns,
230                        fts_table,
231                        vec_tables,
232                        into_id,
233                        from_id,
234                        strategy,
235                        content_strategy,
236                        dry_run,
237                        pack_rules,
238                        preserve_owner_established,
239                        MergeTxLimits::default(),
240                        Some(event_context),
241                        merge_guard,
242                    )
243                    .map_err(|error| match error {
244                        MergeSqlError::Sqlite(error) => error,
245                        MergeSqlError::Refusal(error) => {
246                            refusal = Some(error);
247                            SqliteError::InvalidData(
248                                "note merge refused by transactional policy".to_string(),
249                            )
250                        }
251                    })
252                });
253                match refusal {
254                    Some(error) => Err(error),
255                    None => result.map_err(RuntimeError::from),
256                }
257            })
258            .await
259            .map_err(|e| RuntimeError::Internal(e.to_string()))??
260        };
261
262        // Count only committed event rows; dry-run never inserts an event.
263        if !dry_run {
264            khive_storage::usage::account_event_write(Ok(1));
265            tracing::info!(
266                into_id = %summary.kept_id,
267                from_id = %summary.removed_id,
268                budget_rows = summary.tx_budget.rows_charged,
269                budget_bytes = summary.tx_budget.bytes_charged,
270                budget_max_rows = summary.tx_budget.max_rows,
271                budget_max_bytes = summary.tx_budget.max_bytes,
272                "merge_note: transaction materialization budget"
273            );
274        }
275
276        if !dry_run {
277            if !embedding_plan.is_empty() {
278                #[cfg(any(test, feature = "fault-injection"))]
279                let reindex_result =
280                    if crate::operations::consume_fts_fail_fault(&updated_note.namespace) {
281                        Err(RuntimeError::Internal("injected FTS failure".to_string()))
282                    } else {
283                        self.reindex_note_report_with_plan(token, &updated_note, &embedding_plan)
284                            .await
285                    };
286                #[cfg(not(any(test, feature = "fault-injection")))]
287                let reindex_result = self
288                    .reindex_note_report_with_plan(token, &updated_note, &embedding_plan)
289                    .await;
290
291                match reindex_result {
292                    Ok(report) => {
293                        summary.embedding_truncation = report.truncation;
294                        if !report.failures.is_empty() {
295                            summary.post_commit_reindex_error = Some(
296                                report
297                                    .failures
298                                    .iter()
299                                    .map(|failure| {
300                                        format!(
301                                            "model {} {}: {}",
302                                            failure.model,
303                                            failure.stage.as_str(),
304                                            failure.error
305                                        )
306                                    })
307                                    .collect::<Vec<_>>()
308                                    .join("; "),
309                            );
310                        }
311                    }
312                    Err(error) => {
313                        tracing::warn!(
314                            into_id = %summary.kept_id,
315                            from_id = %summary.removed_id,
316                            error = %error,
317                            "merge_note: committed merge but survivor reindex failed"
318                        );
319                        summary.post_commit_reindex_error = Some(error.to_string());
320                    }
321                }
322            }
323            // The note row is committed even when no embedding model is
324            // registered or the post-commit reindex reports an error.
325            self.fire_note_mutation_hook(&updated_note.kind, updated_note.id)
326                .await;
327        }
328
329        Ok((summary, updated_note.version))
330    }
331}