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