Skip to main content

khive_runtime/operations/
delete_restore.rs

1use chrono::Utc;
2use uuid::Uuid;
3
4use khive_db::stores::entity::entity_hard_delete_statement;
5use khive_db::stores::event::hard_delete_lineage_warning_statements;
6use khive_db::stores::graph::purge_incident_edges_statement;
7use khive_db::stores::note::note_hard_delete_statement;
8use khive_db::stores::text::insert_document_statements;
9use khive_storage::types::{DeleteMode, SqlStatement, SqlValue};
10use khive_storage::{AttachmentSubstrate, Edge, Entity};
11use khive_types::{EventKind, SubstrateKind};
12
13use crate::atomic_plan::{
14    AffectedRowGuard, DeletePlan, PlanStatement, PostCommitEffect, UpdatePlan,
15};
16use crate::atomic_runner::{run_atomic_unit, AtomicOpFailure, AtomicOpPlan, AtomicRunOutcome};
17use crate::curation::entity_fts_document;
18use crate::error::{RuntimeError, RuntimeResult};
19use crate::runtime::{KhiveRuntime, NamespaceToken};
20
21#[cfg(any(test, feature = "fault-injection"))]
22use super::{consume_fault, FTS_FAIL_NS, ROLLBACK_CLEANUP_FAIL_NS};
23use super::{
24    legacy_post_commit_result, live_merged_entity_refused, merge_tombstone_restore_refused,
25    record_post_commit_degradation, restore_reindex_failed, PostCommitDegradation,
26};
27
28impl KhiveRuntime {
29    /// Hard-delete a single graph node (entity, note, or edge-as-node row) AND purge its
30    /// incident edges in ONE write transaction — closes a race where a concurrent guarded
31    /// write could insert a fresh edge against the endpoint between two separately
32    /// committed calls; see docs/operations.md#atomic_hard_delete_with_edge_purge.
33    ///
34    /// `row_statement` is the exact hard-delete `DELETE` for the target row
35    /// (entity, note, or edge). Before the incident-edge purge, the same plan
36    /// appends any ADR-002 lineage-loss warnings from the still-present edge
37    /// rows, so the warning payload and cascade commit or roll back together.
38    /// Returns `Ok(true)` if the row was deleted, `Ok(false)` if it no longer
39    /// existed (lost a race with a concurrent delete of the same row).
40    pub(super) async fn atomic_hard_delete_with_edge_purge(
41        &self,
42        row_statement: SqlStatement,
43        node_id: Uuid,
44        namespace: &str,
45        actor: &str,
46        substrate: SubstrateKind,
47    ) -> RuntimeResult<bool> {
48        let mut statements = vec![PlanStatement {
49            statement: row_statement,
50            guard: Some(AffectedRowGuard::exactly(1)),
51        }];
52        if matches!(substrate, SubstrateKind::Entity | SubstrateKind::Note) {
53            statements.push(PlanStatement {
54                statement: khive_db::stores::attachment::delete_record_attachments_statement(
55                    node_id,
56                    if substrate == SubstrateKind::Entity {
57                        AttachmentSubstrate::Entity
58                    } else {
59                        AttachmentSubstrate::Note
60                    },
61                ),
62                guard: None,
63            });
64        }
65        statements.extend(
66            hard_delete_lineage_warning_statements(namespace, actor, node_id, substrate)
67                .into_iter()
68                .map(|statement| PlanStatement {
69                    statement,
70                    guard: None,
71                }),
72        );
73        statements.push(PlanStatement {
74            statement: purge_incident_edges_statement(node_id),
75            guard: None,
76        });
77        let plan = AtomicOpPlan::Delete(DeletePlan {
78            target_id: node_id,
79            statements,
80            post_commit: PostCommitEffect::None,
81        });
82        match run_atomic_unit(self.sql().as_ref(), vec![plan]).await {
83            Ok(AtomicRunOutcome::Committed { .. }) => Ok(true),
84            Ok(AtomicRunOutcome::RolledBack {
85                failure: AtomicOpFailure::NoteConflict(conflict),
86                ..
87            }) => Err(conflict.into_error().into()),
88            Ok(AtomicRunOutcome::RolledBack {
89                failure: AtomicOpFailure::EntityConflict(conflict),
90                ..
91            }) => Err(conflict.into_error().into()),
92            Ok(AtomicRunOutcome::RolledBack {
93                failure: AtomicOpFailure::GuardFailed { .. },
94                ..
95            }) => Ok(false),
96            Ok(AtomicRunOutcome::RolledBack {
97                failure: AtomicOpFailure::SqlError { message, .. },
98                ..
99            }) => Err(RuntimeError::Internal(format!(
100                "hard delete + edge purge for {node_id} failed: {message}"
101            ))),
102            Err(e) => Err(RuntimeError::Internal(format!(
103                "hard delete + edge purge for {node_id}: atomic unit seam failure: {}",
104                e.0
105            ))),
106        }
107    }
108
109    /// Restore an entity tombstone owned by the caller's primary namespace.
110    ///
111    /// The restore is guarded by both the tombstone's id/namespace and the
112    /// current uniqueness state, so a caller cannot resurrect over a newer
113    /// live record. Indexes are rebuilt only after the row restore commits.
114    pub async fn restore_entity(
115        &self,
116        token: &NamespaceToken,
117        id: Uuid,
118    ) -> RuntimeResult<Option<(Entity, bool)>> {
119        let Some(entity) = self
120            .entities(token)?
121            .get_entity_including_deleted(id)
122            .await?
123        else {
124            return Ok(None);
125        };
126        if entity.namespace != token.namespace().as_str() {
127            return Ok(None);
128        }
129        // A merge tombstone is not a plain soft delete: the source row carries
130        // merge provenance and its content already lives on the kept entity.
131        // Clearing only `deleted_at` would bring the source back as a live
132        // duplicate that still claims to have been merged. Refuse and name
133        // the kept id; restore does not undo a merge.
134        //
135        // The merge check runs before the already-live short cut on purpose:
136        // a live row that still carries `merged_into` is what an earlier
137        // restore left behind before this guard existed, and answering it
138        // "already live" would hide the invariant violation from the one
139        // caller who is looking at the row. Name it instead.
140        if let Some(kept_id) = entity.merged_into {
141            if entity.deleted_at.is_none() {
142                return Err(live_merged_entity_refused(id, kept_id));
143            }
144            return Err(merge_tombstone_restore_refused(id, kept_id));
145        }
146        if entity.deleted_at.is_none() {
147            return Ok(Some((entity, false)));
148        }
149        let updated_at =
150            Utc::now()
151                .timestamp_micros()
152                .max(entity.updated_at.checked_add(1).ok_or_else(|| {
153                    RuntimeError::Internal(format!(
154                        "entity {id} updated_at is already at i64::MAX and cannot advance"
155                    ))
156                })?);
157        let mut restored = entity;
158        restored.deleted_at = None;
159        restored.updated_at = updated_at;
160        restored.version = restored
161            .version
162            .checked_add(1)
163            .ok_or_else(|| RuntimeError::InvalidInput("entity version overflow".into()))?;
164        let mut statements = vec![PlanStatement {
165            statement: SqlStatement {
166                sql: "UPDATE entities SET deleted_at=NULL, updated_at=?1, version=version+1 \
167                      WHERE id=?2 AND namespace=?3 AND deleted_at IS NOT NULL AND version=?4"
168                    .into(),
169                params: vec![
170                    SqlValue::Integer(updated_at),
171                    SqlValue::Text(id.to_string()),
172                    SqlValue::Text(token.namespace().as_str().to_owned()),
173                    SqlValue::Integer(restored.version - 1),
174                ],
175                label: Some("entity-restore".into()),
176            },
177            guard: Some(AffectedRowGuard::exactly(1)),
178        }];
179        // The soft delete removed the FTS row, so the text index is published
180        // in the same unit as the row: a live row that search cannot find is
181        // not a state this verb can leave behind. Order-sensitive pair — see
182        // `insert_document_statements`'s adjacency contract.
183        for statement in khive_db::stores::text::delete_document_statements(
184            "fts_entities",
185            &restored.namespace,
186            id,
187        )
188        .into_iter()
189        .chain(insert_document_statements(
190            "fts_entities",
191            &entity_fts_document(&restored),
192        )) {
193            statements.push(PlanStatement {
194                statement,
195                guard: None,
196            });
197        }
198        let plan = AtomicOpPlan::Update(Box::new(UpdatePlan {
199            graph_effects: Vec::new(),
200            target_id: id,
201            statements,
202            post_commit: PostCommitEffect::None,
203            edge_natural_key: None,
204            idempotent_noop: false,
205            entity_guard: None,
206            note_guard: None,
207            note_vector_purge: None,
208            note_embedding_inheritance: None,
209        }));
210        match run_atomic_unit(self.sql().as_ref(), vec![plan]).await {
211            Ok(AtomicRunOutcome::Committed { .. }) => {
212                // Embeddings are rebuilt after the commit; the row and its
213                // text index are already live, so a failure here names that.
214                #[cfg(any(test, feature = "fault-injection"))]
215                if consume_fault(&FTS_FAIL_NS, &restored.namespace) {
216                    return Err(restore_reindex_failed(
217                        "entity",
218                        id,
219                        RuntimeError::Internal("injected FTS failure".to_string()),
220                    ));
221                }
222                self.reindex_entity(token, &restored)
223                    .await
224                    .map_err(|e| restore_reindex_failed("entity", id, e))?;
225                Ok(Some((restored, true)))
226            }
227            Ok(AtomicRunOutcome::RolledBack { failure, .. }) => Err(RuntimeError::Internal(
228                format!("entity restore rolled back: {failure:?}"),
229            )),
230            Err(error) => Err(RuntimeError::Storage(error.0)),
231        }
232    }
233
234    /// Restore an edge tombstone owned by the caller's primary namespace.
235    pub async fn restore_edge(
236        &self,
237        token: &NamespaceToken,
238        id: Uuid,
239    ) -> RuntimeResult<Option<(Edge, bool)>> {
240        let Some(edge) = self.get_edge_including_deleted(token, id).await? else {
241            return Ok(None);
242        };
243        if edge.namespace != token.namespace().as_str() {
244            return Ok(None);
245        }
246        if edge.deleted_at.is_none() {
247            return Ok(Some((edge, false)));
248        }
249        let updated_at = Utc::now();
250        let plan = AtomicOpPlan::Update(Box::new(UpdatePlan {
251            graph_effects: Vec::new(),
252            target_id: id,
253            statements: vec![PlanStatement {
254                statement: SqlStatement {
255                    sql: "UPDATE graph_edges SET deleted_at=NULL, updated_at=?1 \
256                          WHERE id=?2 AND namespace=?3 AND deleted_at IS NOT NULL"
257                        .into(),
258                    params: vec![
259                        SqlValue::Integer(updated_at.timestamp_micros()),
260                        SqlValue::Text(id.to_string()),
261                        SqlValue::Text(edge.namespace.clone()),
262                    ],
263                    label: Some("edge-restore".into()),
264                },
265                guard: Some(AffectedRowGuard::exactly(1)),
266            }],
267            post_commit: PostCommitEffect::None,
268            edge_natural_key: None,
269            idempotent_noop: false,
270            entity_guard: None,
271            note_guard: None,
272            note_vector_purge: None,
273            note_embedding_inheritance: None,
274        }));
275        match run_atomic_unit(self.sql().as_ref(), vec![plan]).await {
276            Ok(AtomicRunOutcome::Committed { .. }) => {
277                let mut restored = edge;
278                restored.deleted_at = None;
279                restored.updated_at = updated_at;
280                Ok(Some((restored, true)))
281            }
282            Ok(AtomicRunOutcome::RolledBack { failure, .. }) => Err(RuntimeError::Internal(
283                format!("edge restore rolled back: {failure:?}"),
284            )),
285            Err(error) => Err(RuntimeError::Storage(error.0)),
286        }
287    }
288
289    /// Soft-delete or hard-delete a note by ID.
290    ///
291    /// On hard delete, cascades to remove all incident edges (both inbound and
292    /// outbound) and cleans up FTS and vector indexes, preventing dangling
293    /// references for `annotates` edges that target this note.
294    /// Soft delete also cleans FTS and vector indexes; edges are left in place.
295    ///
296    /// UUID v4 is globally unique: no namespace filter on by-ID ops.
297    /// Cascade and index cleanup target the RECORD's stored namespace, not the caller token's.
298    /// Returns `Ok(false)` if the note does not exist.
299    pub async fn delete_note(
300        &self,
301        token: &NamespaceToken,
302        id: Uuid,
303        hard: bool,
304    ) -> RuntimeResult<bool> {
305        let (deleted, degradations) = self
306            .delete_note_with_post_commit_report(token, id, hard)
307            .await?;
308        legacy_post_commit_result("delete_note", id, deleted, degradations)
309    }
310
311    /// Delete the note and return diagnostics for any failed work after the
312    /// row change committed. A degradation is non-retryable: callers must not
313    /// repeat a create or delete because an index or telemetry leg failed.
314    pub async fn delete_note_with_post_commit_report(
315        &self,
316        token: &NamespaceToken,
317        id: Uuid,
318        hard: bool,
319    ) -> RuntimeResult<(bool, Vec<PostCommitDegradation>)> {
320        let note_store = self.notes(token)?;
321        let note = if hard {
322            match note_store.get_note_including_deleted(id).await? {
323                Some(n) => n,
324                None => return Ok((false, Vec::new())),
325            }
326        } else {
327            match note_store.get_note(id).await? {
328                Some(n) => n,
329                None => return Ok((false, Vec::new())),
330            }
331        };
332        if let Some(error) = self.stream_member_error(&note).await? {
333            return Err(error);
334        }
335        let mode = if hard {
336            DeleteMode::Hard
337        } else {
338            DeleteMode::Soft
339        };
340
341        // Route index cleanup through the RECORD's namespace, not the caller's.
342        let record_tok = token.with_namespace(
343            khive_types::Namespace::parse(&note.namespace)
344                .map_err(|e| RuntimeError::Internal(format!("note namespace invalid: {e}")))?,
345        );
346        let record_ns = note.namespace.clone();
347        let actor = format!("{}:{}", token.actor().kind, token.actor().id);
348
349        // On hard delete, the row delete and the incident-edge cascade (including
350        // already-soft-deleted edges) run as ONE write transaction: see
351        // `atomic_hard_delete_with_edge_purge`. Index cleanup follows the
352        // commit; it is best-effort and idempotent, unlike the row/edge pair.
353        let deleted = if hard {
354            self.atomic_hard_delete_with_edge_purge(
355                note_hard_delete_statement(id),
356                id,
357                &record_ns,
358                &actor,
359                SubstrateKind::Note,
360            )
361            .await?
362        } else {
363            note_store.delete_note(id, mode).await?
364        };
365        let mut degradations = Vec::new();
366        if deleted {
367            let fts_result = match self.text_for_notes(&record_tok) {
368                Ok(store) => store
369                    .delete_document(&record_ns, id)
370                    .await
371                    .map_err(RuntimeError::from),
372                Err(error) => Err(error),
373            };
374            if let Err(error) = fts_result {
375                record_post_commit_degradation(
376                    &mut degradations,
377                    "delete_note",
378                    id,
379                    "fts_cleanup",
380                    error,
381                );
382            }
383            // Try every model even when FTS or another model failed.
384            for model_name in self.registered_embedding_model_names() {
385                let vector_result = match self.vectors_for_model(&record_tok, &model_name) {
386                    Ok(store) => store.delete(id).await.map_err(RuntimeError::from),
387                    Err(error) => Err(error),
388                };
389                if let Err(error) = vector_result {
390                    record_post_commit_degradation(
391                        &mut degradations,
392                        "delete_note",
393                        id,
394                        "vector_cleanup",
395                        format!("{model_name}: {error}"),
396                    );
397                }
398            }
399            let event = khive_storage::event::Event::new(
400                record_ns.clone(),
401                "delete",
402                EventKind::NoteDeleted,
403                SubstrateKind::Note,
404                "",
405            )
406            .with_target(id)
407            .with_payload(serde_json::json!({"id": id, "namespace": record_ns, "hard": hard}));
408            let event_result = match self.events(&record_tok) {
409                Ok(store) => store.append_event(event).await.map_err(RuntimeError::from),
410                Err(error) => Err(error),
411            };
412            if let Err(error) = event_result {
413                record_post_commit_degradation(
414                    &mut degradations,
415                    "delete_note",
416                    id,
417                    "event_append",
418                    error,
419                );
420            }
421            // A soft OR hard delete removes the note's vectors/FTS document
422            // above: any pack-owned vector-derived cache (e.g.
423            // khive-pack-memory's warm ANN index) needs to know the corpus
424            // changed, reached via this generic hook so khive-runtime never
425            // takes a dependency on khive-pack-memory. No-op when no pack has
426            // installed a hook.
427            self.fire_note_mutation_hook(&note.kind, id).await;
428        }
429        Ok((deleted, degradations))
430    }
431
432    /// Row-first compensating delete for rolling back a partially-written note
433    /// (e.g. `dual_write_message` rollback after a later delivery step fails).
434    /// Unlike [`KhiveRuntime::delete_note`], which cleans up graph/FTS/
435    /// vector indexes *before* removing the row, this removes the row first so
436    /// that a cleanup failure afterward cannot leave the compensated note live.
437    ///
438    /// Returns `Ok(())` once the row is gone (whether or not cleanup fully
439    /// succeeded). Returns `Err(RuntimeError::Internal)` naming the failed
440    /// cleanup legs when row removal succeeded but cleanup did not — the
441    /// message is gone, but stale index entries may remain and should be
442    /// surfaced to the caller rather than silently discarded.
443    ///
444    /// Returns `Ok(())` immediately, with no cleanup attempted, if the note
445    /// does not exist (nothing to compensate).
446    ///
447    /// Not a general-purpose replacement for `delete_note(..., hard=true)`:
448    /// normal hard delete still needs cleanup-first semantics (no dangling
449    /// references) since a caller-visible error there should not remove the row.
450    pub async fn delete_note_row_first_for_compensation(
451        &self,
452        token: &NamespaceToken,
453        id: Uuid,
454    ) -> RuntimeResult<()> {
455        let note_store = self.notes(token)?;
456        let Some(note) = note_store.get_note_including_deleted(id).await? else {
457            return Ok(());
458        };
459        let record_tok = NamespaceToken::for_namespace(
460            khive_types::Namespace::parse(&note.namespace)
461                .map_err(|e| RuntimeError::Internal(format!("note namespace invalid: {e}")))?,
462        );
463        let record_ns = note.namespace.clone();
464
465        // Critical ordering: remove the row before any cleanup that can fail.
466        note_store.delete_note(id, DeleteMode::Hard).await?;
467
468        #[cfg(any(test, feature = "fault-injection"))]
469        {
470            let armed = ROLLBACK_CLEANUP_FAIL_NS.lock().unwrap().take();
471            if armed.as_deref() == Some(record_ns.as_str()) {
472                return Err(RuntimeError::Internal(
473                    "row removed but compensation cleanup failed: injected=true".to_string(),
474                ));
475            }
476        }
477
478        let mut cleanup_errors = Vec::new();
479        if let Err(e) = self.graph(&record_tok)?.purge_incident_edges(id).await {
480            cleanup_errors.push(format!("graph={e}"));
481        }
482        if let Err(e) = self
483            .text_for_notes(&record_tok)?
484            .delete_document(&record_ns, id)
485            .await
486        {
487            cleanup_errors.push(format!("fts={e}"));
488        }
489        for model_name in self.registered_embedding_model_names() {
490            if let Err(e) = self
491                .vectors_for_model(&record_tok, &model_name)?
492                .delete(id)
493                .await
494            {
495                cleanup_errors.push(format!("vector[{model_name}]={e}"));
496            }
497        }
498        if cleanup_errors.is_empty() {
499            Ok(())
500        } else {
501            Err(RuntimeError::Internal(format!(
502                "row removed but compensation cleanup failed: {}",
503                cleanup_errors.join("; ")
504            )))
505        }
506    }
507
508    /// Soft-delete or hard-delete an entity by ID (soft delete by default).
509    ///
510    /// On hard delete, cascades to remove all incident edges (both inbound and
511    /// outbound) to prevent dangling references. Soft delete also cleans FTS
512    /// and vector indexes; edges are left in place.
513    /// Routed attachment cleanup is performed by the registry after ownership resolution.
514    ///
515    /// UUID v4 is globally unique: no namespace filter on by-ID ops.
516    pub async fn delete_entity(
517        &self,
518        token: &NamespaceToken,
519        id: Uuid,
520        hard: bool,
521    ) -> RuntimeResult<bool> {
522        let (deleted, degradations) = self
523            .delete_entity_with_post_commit_report(token, id, hard)
524            .await?;
525        legacy_post_commit_result("delete_entity", id, deleted, degradations)
526    }
527
528    /// The committed delete together with non-retryable index/telemetry errors.
529    pub async fn delete_entity_with_post_commit_report(
530        &self,
531        token: &NamespaceToken,
532        id: Uuid,
533        hard: bool,
534    ) -> RuntimeResult<(bool, Vec<PostCommitDegradation>)> {
535        let entity = if hard {
536            match self
537                .entities(token)?
538                .get_entity_including_deleted(id)
539                .await?
540            {
541                Some(e) => e,
542                None => return Ok((false, Vec::new())),
543            }
544        } else {
545            match self.entities(token)?.get_entity(id).await? {
546                Some(e) => e,
547                None => return Ok((false, Vec::new())),
548            }
549        };
550        let mode = if hard {
551            DeleteMode::Hard
552        } else {
553            DeleteMode::Soft
554        };
555
556        // Route cascade and index cleanup through the RECORD's namespace, not the caller's.
557        let record_tok = token.with_namespace(
558            khive_types::Namespace::parse(&entity.namespace)
559                .map_err(|e| RuntimeError::Internal(format!("entity namespace invalid: {e}")))?,
560        );
561        let actor = format!("{}:{}", token.actor().kind, token.actor().id);
562
563        // On hard delete, the row delete and the incident-edge cascade (including
564        // already-soft-deleted edges) run as ONE write transaction: see
565        // `atomic_hard_delete_with_edge_purge`. Index cleanup follows the
566        // commit; it is best-effort and idempotent, unlike the row/edge pair.
567        let deleted = if hard {
568            // Cross-backend attachment cleanup requires the registry's ownership check.
569            self.atomic_hard_delete_with_edge_purge(
570                entity_hard_delete_statement(id),
571                id,
572                &entity.namespace,
573                &actor,
574                SubstrateKind::Entity,
575            )
576            .await?
577        } else {
578            self.entities(token)?.delete_entity(id, mode).await?
579        };
580        let mut degradations = Vec::new();
581        if deleted {
582            let ns = entity.namespace.clone();
583            let fts_result = match self.text(&record_tok) {
584                Ok(store) => store
585                    .delete_document(&ns, id)
586                    .await
587                    .map_err(RuntimeError::from),
588                Err(error) => Err(error),
589            };
590            if let Err(error) = fts_result {
591                record_post_commit_degradation(
592                    &mut degradations,
593                    "delete_entity",
594                    id,
595                    "fts_cleanup",
596                    error,
597                );
598            }
599            for model_name in self.registered_embedding_model_names() {
600                let vector_result = match self.vectors_for_model(&record_tok, &model_name) {
601                    Ok(store) => store.delete(id).await.map_err(RuntimeError::from),
602                    Err(error) => Err(error),
603                };
604                if let Err(error) = vector_result {
605                    record_post_commit_degradation(
606                        &mut degradations,
607                        "delete_entity",
608                        id,
609                        "vector_cleanup",
610                        format!("{model_name}: {error}"),
611                    );
612                }
613            }
614            let event = khive_storage::event::Event::new(
615                ns.clone(),
616                "delete",
617                EventKind::EntityDeleted,
618                SubstrateKind::Entity,
619                "",
620            )
621            .with_target(id)
622            .with_payload(serde_json::json!({"id": id, "namespace": ns, "hard": hard}));
623            let event_result = match self.events(&record_tok) {
624                Ok(store) => store.append_event(event).await.map_err(RuntimeError::from),
625                Err(error) => Err(error),
626            };
627            if let Err(error) = event_result {
628                record_post_commit_degradation(
629                    &mut degradations,
630                    "delete_entity",
631                    id,
632                    "event_append",
633                    error,
634                );
635            }
636        }
637        Ok((deleted, degradations))
638    }
639
640    pub(crate) async fn delete_entity_attachments_on_core(&self, id: Uuid) -> RuntimeResult<bool> {
641        let core = self.core();
642        drop(core.attachments()?);
643        let statement = khive_db::stores::attachment::delete_record_attachments_statement(
644            id,
645            AttachmentSubstrate::Entity,
646        );
647        Ok(core.sql().writer().await?.execute(statement).await? > 0)
648    }
649}