Skip to main content

khive_runtime/atomic_prepare/
edge_delete.rs

1use super::{
2    canonical_edge_endpoints, delete_record_attachments_statement, edge_hard_delete_statement,
3    edge_replace_if_unchanged_statement, edge_soft_delete_statement,
4    edge_symmetric_absorb_or_update_inplace_statement, edge_symmetric_delete_if_conflict_statement,
5    entity_hard_delete_statement, entity_soft_delete_statement, event_append_statements,
6    hard_delete_lineage_warning_statements, note_hard_delete_statement, note_soft_delete_statement,
7    obj, optional_f64, optional_properties, optional_str, parse_edge_relation,
8    purge_incident_edges_statement, push_index_purge_statements, refuse_pack_registry_tags,
9    reject_inapplicable_update_fields, require_uuid, AffectedRowGuard, AtomicOpPlan,
10    AttachmentSubstrate, DeletePlan, EdgeNaturalKey, EventKind, KhiveRuntime, NamespaceToken,
11    PlanStatement, PostCommitEffect, Resolved, RuntimeError, RuntimeResult, SubstrateKind,
12    UpdatePlan, Uuid, Value,
13};
14
15/// Edge branch of `prepare_update`. Mirrors `KhiveRuntime::update_edge`'s
16/// patch semantics: `relation`/`weight`/`properties` are the only applicable
17/// fields, a changed `relation` is endpoint-validated first, `weight` is
18/// range-checked, and `properties` REPLACES `metadata` wholesale (no merge).
19/// See `docs/api/atomic_prepare.md#prepare_update_edge` for the DML-shape parity
20/// detail with `update_edge`.
21///
22/// Invariant (symmetric relations `competes_with`/`composed_with`): this
23/// function must never branch on a prepare-time conflict probe — a different
24/// op in the same atomic unit could change the conflict landscape between
25/// probe and commit, making any such branch stale by construction. It always
26/// emits BOTH statements from [`edge_symmetric_delete_if_conflict_statement`]
27/// and [`edge_symmetric_absorb_or_update_inplace_statement`], each carrying
28/// its own commit-time `WHERE`/`CASE WHEN` predicate that re-evaluates the
29/// conflict condition fresh inside the transaction. This function reads no
30/// state to guess a surviving id; the plan instead carries `edge_natural_key`
31/// so a post-commit caller derives the actual surviving id from the
32/// committed row, never from a value computed before the rest of this atomic
33/// unit has even run.
34pub(super) async fn prepare_update_edge(
35    runtime: &KhiveRuntime,
36    token: &NamespaceToken,
37    id: Uuid,
38    mut edge: khive_storage::types::Edge,
39    args: &Value,
40) -> RuntimeResult<AtomicOpPlan> {
41    reject_inapplicable_update_fields(args, "edge")?;
42
43    let expected_updated_at = edge.updated_at;
44    let expected_deleted_at = edge.deleted_at;
45
46    let relation_raw = optional_str(args, "relation");
47    let weight = optional_f64(args, "weight")?;
48    let properties = optional_properties(args, "properties")?;
49
50    if let Some(ref p) = properties {
51        crate::secret_gate::check_json_at(p, "edge", "properties")?;
52    }
53    crate::secret_gate::reject_reserved_secret_gate_property(properties.as_ref())?;
54
55    let namespace = edge.namespace.clone();
56    let record_tok = token.with_namespace(
57        khive_types::Namespace::parse(&namespace)
58            .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
59    );
60
61    let mut changed_fields: Vec<&'static str> = Vec::new();
62    if let Some(raw) = relation_raw {
63        let relation = parse_edge_relation(raw)?;
64        runtime
65            .validate_edge_relation_endpoints(&record_tok, edge.source_id, edge.target_id, relation)
66            .await?;
67        edge.relation = relation;
68        changed_fields.push("relation");
69    }
70    if let Some(w) = weight {
71        if !khive_types::validate_edge_weight(w) {
72            return Err(RuntimeError::InvalidInput(format!(
73                "edge weight must be a finite value in [0.0, 1.0]; got {w}"
74            )));
75        }
76        edge.weight = w;
77        changed_fields.push("weight");
78    }
79    if let Some(p) = properties {
80        edge.metadata = Some(p);
81        changed_fields.push("properties");
82    }
83
84    prepare_update_edge_plan(
85        token,
86        id,
87        edge,
88        expected_updated_at,
89        expected_deleted_at,
90        changed_fields,
91        chrono::Utc::now().timestamp_micros(),
92    )
93}
94
95pub(crate) fn prepare_update_edge_plan(
96    token: &NamespaceToken,
97    id: Uuid,
98    mut edge: khive_storage::Edge,
99    expected_updated_at: chrono::DateTime<chrono::Utc>,
100    expected_deleted_at: Option<chrono::DateTime<chrono::Utc>>,
101    changed_fields: Vec<&'static str>,
102    now_micros: i64,
103) -> RuntimeResult<AtomicOpPlan> {
104    let namespace = edge.namespace.clone();
105    let (canon_src, canon_tgt) =
106        canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
107    let now = chrono::DateTime::from_timestamp_micros(now_micros).ok_or_else(|| {
108        RuntimeError::InvalidInput("invalid canonical edge write timestamp".into())
109    })?;
110
111    let mut statements: Vec<PlanStatement> = Vec::new();
112    let mut edge_natural_key: Option<EdgeNaturalKey> = None;
113
114    if edge.relation.is_symmetric() {
115        // The write for a symmetric relation never branches on a
116        // prepare-time probe result: it always carries both self-guarding,
117        // commit-time-predicate statements (see their doc comment in
118        // khive-db's graph.rs for the full rationale). This avoids the
119        // staleness window a prepare-time probe would expose: an earlier op
120        // in the same atomic unit could change the conflict landscape before
121        // commit. Canonical's own probe-then-branch
122        // `update_edge_symmetric_dml` has no such exposure (single
123        // transaction, no interleaving) and is unaffected.
124        let metadata_str = edge
125            .metadata
126            .as_ref()
127            .map(|v| serde_json::to_string(v).unwrap_or_default());
128
129        // `updated_at` must strictly advance past the snapshot even when two
130        // operations land inside one clock microsecond; saturating to
131        // i64::MAX would let the CAS accept a write without advancing its
132        // revision, so that is not a valid fallback (mirrors the note path).
133        let minimum_updated_at_micros = expected_updated_at
134            .timestamp_micros()
135            .checked_add(1)
136            .ok_or_else(|| {
137                RuntimeError::Internal(format!(
138                    "edge {id} updated_at is already at i64::MAX and cannot advance"
139                ))
140            })?;
141        let symmetric_updated_at_micros = now.timestamp_micros().max(minimum_updated_at_micros);
142        let expected_deleted_at_micros = expected_deleted_at.map(|v| v.timestamp_micros());
143
144        statements.push(PlanStatement {
145            statement: edge_symmetric_delete_if_conflict_statement(
146                &namespace,
147                id,
148                canon_src,
149                canon_tgt,
150                edge.relation,
151                expected_updated_at.timestamp_micros(),
152                expected_deleted_at_micros,
153            ),
154            guard: Some(AffectedRowGuard {
155                expected_min: 0,
156                expected_max: Some(1),
157            }),
158        });
159        statements.push(PlanStatement {
160            statement: edge_symmetric_absorb_or_update_inplace_statement(
161                &namespace,
162                id,
163                canon_src,
164                canon_tgt,
165                edge.relation,
166                edge.weight,
167                symmetric_updated_at_micros,
168                metadata_str.as_deref(),
169                edge.target_backend.as_deref(),
170                expected_updated_at.timestamp_micros(),
171                expected_deleted_at_micros,
172            ),
173            guard: Some(AffectedRowGuard::exactly(1)),
174        });
175
176        // No prepare-time read needed: the two statements above are
177        // self-guarding at commit time (see their doc comment). Post-commit
178        // result rendering derives the actual surviving id from THIS
179        // natural key, never from a value computed here.
180        edge_natural_key = Some(EdgeNaturalKey {
181            namespace: namespace.clone(),
182            canon_source_id: canon_src,
183            canon_target_id: canon_tgt,
184            relation: edge.relation,
185        });
186    } else {
187        // Non-symmetric: guarded replace of the read snapshot rather than
188        // `graph.upsert_edge`'s unconditional natural-key upsert — a zero
189        // affected-row result now means a concurrent writer moved this edge
190        // between PREPARE and commit, and the atomic unit must roll back
191        // instead of silently overwriting it.
192        //
193        // `updated_at` must strictly advance past the snapshot even when two
194        // operations land inside one clock microsecond; saturating to
195        // i64::MAX would let the CAS accept a write without advancing its
196        // revision, so that is not a valid fallback (mirrors the note path).
197        let minimum_updated_at_micros = expected_updated_at
198            .timestamp_micros()
199            .checked_add(1)
200            .ok_or_else(|| {
201                RuntimeError::Internal(format!(
202                    "edge {id} updated_at is already at i64::MAX and cannot advance"
203                ))
204            })?;
205        let now_micros = now.timestamp_micros().max(minimum_updated_at_micros);
206        edge.updated_at = chrono::DateTime::from_timestamp_micros(now_micros).ok_or_else(|| {
207            RuntimeError::Internal(format!(
208                "edge {id}: computed updated_at {now_micros} is not a valid timestamp"
209            ))
210        })?;
211        statements.push(PlanStatement {
212            statement: edge_replace_if_unchanged_statement(
213                &edge,
214                expected_updated_at,
215                expected_deleted_at,
216            ),
217            guard: Some(AffectedRowGuard::exactly(1)),
218        });
219    }
220
221    // Mirrors `update_edge`'s unconditional post-mutation `EdgeUpdated`
222    // event append, keyed on the original `edge_id` the caller supplied:
223    // canonical does the same (the event target is `edge_id`, not the
224    // post-absorption surviving id).
225    statements.extend(event_append_statements(
226        token,
227        &namespace,
228        "update",
229        EventKind::EdgeUpdated,
230        SubstrateKind::Entity,
231        id,
232        serde_json::json!({"id": id, "namespace": namespace, "changed_fields": changed_fields}),
233    )?);
234
235    Ok(AtomicOpPlan::Update(Box::new(UpdatePlan {
236        graph_effects: Vec::new(),
237        note_vector_purge: None,
238        note_embedding_inheritance: None,
239        entity_guard: None,
240        note_guard: None,
241        target_id: id,
242        statements,
243        post_commit: PostCommitEffect::None,
244        edge_natural_key,
245        idempotent_noop: false,
246    })))
247}
248
249// ---------------------------------------------------------------------------
250// delete
251// ---------------------------------------------------------------------------
252
253/// Caller-supplied delete-kind expectation, resolved via the canonical
254/// `resolve_kind_spec` at the kkernel `--atomic` seam. `khive-runtime` must
255/// not depend on `khive-pack-kg` (packs depend on the runtime, not the other
256/// way around), so this is a plain substrate-level shape rather than
257/// `khive_pack_kg::handlers::KindSpec` itself: the kkernel seam does the
258/// pack-aware `resolve_kind_spec` resolution (which needs a `VerbRegistry`,
259/// unreachable from this crate) and passes down only what `prepare_delete`
260/// needs to enforce the mismatch check.
261///
262/// `delete` admits `kind="edge"` per `ATOMIC_ADMISSIBLE_VERBS`, hence the
263/// `Edge` variant. `Event`/`Proposal` remain rejected at the kkernel seam
264/// (not v1-admissible for atomic delete at all).
265pub enum AtomicDeleteKind {
266    Entity {
267        specific: Option<String>,
268        entity_type: Option<String>,
269    },
270    Note {
271        specific: Option<String>,
272    },
273    Edge,
274}
275
276/// `expected_kind`: `None` when the caller omitted `kind` (no check, parity
277/// with canonical's own optional discriminator); `Some(_)` enforces an
278/// exact-parity mismatch check against the resolved record's actual
279/// substrate/specific kind, mirroring `handle_delete`'s
280/// `entity.kind != *expected` / `note.kind != *expected` checks.
281pub async fn prepare_delete(
282    runtime: &KhiveRuntime,
283    token: &NamespaceToken,
284    args: &Value,
285    expected_kind: Option<AtomicDeleteKind>,
286) -> RuntimeResult<AtomicOpPlan> {
287    let id = require_uuid(args, "id")?;
288    let hard = obj(args)?
289        .get("hard")
290        .and_then(|v| v.as_bool())
291        .unwrap_or(false);
292
293    // `delete(id, hard=true)` is the public purge route after a prior soft
294    // delete, so it must resolve including already-tombstoned rows (a
295    // live-only resolve would never find one). Soft delete keeps the
296    // live-only resolve: a soft delete of an already-tombstoned row is a
297    // no-op, matching non-atomic behavior.
298    let resolved = if hard {
299        runtime.resolve_by_id_including_deleted(token, id).await?
300    } else {
301        runtime.resolve_by_id(token, id).await?
302    };
303
304    prepare_delete_from_snapshot(runtime, token, args, expected_kind, resolved).await
305}
306
307pub(crate) async fn prepare_delete_from_snapshot(
308    runtime: &KhiveRuntime,
309    token: &NamespaceToken,
310    args: &Value,
311    expected_kind: Option<AtomicDeleteKind>,
312    resolved: Option<Resolved>,
313) -> RuntimeResult<AtomicOpPlan> {
314    let id = require_uuid(args, "id")?;
315    let actor = format!("{}:{}", token.actor().kind, token.actor().id);
316    let hard = obj(args)?
317        .get("hard")
318        .and_then(|v| v.as_bool())
319        .unwrap_or(false);
320
321    match resolved {
322        Some(Resolved::Entity(entity)) => {
323            match &expected_kind {
324                None => {}
325                Some(AtomicDeleteKind::Entity {
326                    specific: Some(expected),
327                    ..
328                }) if &entity.kind != expected => {
329                    return Err(RuntimeError::NotFound(format!("{expected} {id}")));
330                }
331                Some(AtomicDeleteKind::Entity { .. }) => {}
332                Some(AtomicDeleteKind::Note { .. }) => {
333                    return Err(RuntimeError::NotFound(format!("note {id}")));
334                }
335                Some(AtomicDeleteKind::Edge) => {
336                    return Err(RuntimeError::NotFound(format!("edge {id}")));
337                }
338            }
339            if let Some(AtomicDeleteKind::Entity {
340                entity_type: Some(expected),
341                ..
342            }) = &expected_kind
343            {
344                if entity
345                    .entity_type
346                    .as_deref()
347                    .is_some_and(|actual| actual != expected.as_str())
348                {
349                    return Err(RuntimeError::NotFound(format!("entity {id}")));
350                }
351            }
352            refuse_pack_registry_tags(&entity.tags, "delete")?;
353            let namespace = entity.namespace.clone();
354            // Storage parity: `entity_soft_delete_statement`/
355            // `entity_hard_delete_statement` are the SAME khive-db builders
356            // khive-db's own `SqlEntityStore::delete_entity` calls — no DML
357            // text is hand-duplicated here.
358            let mut statements = if hard {
359                vec![
360                    PlanStatement {
361                        statement: delete_record_attachments_statement(
362                            id,
363                            AttachmentSubstrate::Entity,
364                        ),
365                        guard: None,
366                    },
367                    PlanStatement {
368                        statement: entity_hard_delete_statement(id),
369                        guard: Some(AffectedRowGuard::exactly(1)),
370                    },
371                ]
372            } else {
373                let deleted_at = chrono::Utc::now().timestamp_micros();
374                vec![PlanStatement {
375                    statement: entity_soft_delete_statement(id, deleted_at),
376                    guard: Some(AffectedRowGuard::exactly(1)),
377                }]
378            };
379            if hard {
380                statements.extend(
381                    hard_delete_lineage_warning_statements(
382                        &namespace,
383                        &actor,
384                        id,
385                        SubstrateKind::Entity,
386                    )
387                    .into_iter()
388                    .map(|statement| PlanStatement {
389                        statement,
390                        guard: None,
391                    }),
392                );
393                // Same builder canonical `delete_entity`'s hard-delete
394                // cascade calls (`graph.purge_incident_edges`).
395                statements.push(PlanStatement {
396                    statement: purge_incident_edges_statement(id),
397                    guard: None,
398                });
399            }
400            // FTS + vector index purge, matching operations.rs
401            // `delete_entity`: both soft and hard delete clean indexes (a
402            // hard delete of an already-tombstoned record must still purge
403            // them); only hard additionally cascades edges above.
404            push_index_purge_statements(
405                runtime,
406                &mut statements,
407                "fts_entities",
408                &namespace,
409                id,
410                "atomic-delete-entity",
411            )
412            .await?;
413            // operations.rs's `delete_entity` appends an `EntityDeleted`
414            // event after a successful row delete, on both soft and hard
415            // delete. `apply_plan` never reaches this statement unless the
416            // guarded row statement above affected a row, so no extra `if`
417            // is needed here.
418            statements.extend(event_append_statements(
419                token,
420                &namespace,
421                "delete",
422                EventKind::EntityDeleted,
423                SubstrateKind::Entity,
424                id,
425                serde_json::json!({"id": id, "namespace": namespace, "hard": hard}),
426            )?);
427            Ok(AtomicOpPlan::Delete(DeletePlan {
428                target_id: id,
429                statements,
430                post_commit: PostCommitEffect::None,
431            }))
432        }
433        Some(Resolved::Note(note)) => {
434            match &expected_kind {
435                None => {}
436                Some(AtomicDeleteKind::Note {
437                    specific: Some(expected),
438                }) if &note.kind != expected => {
439                    return Err(RuntimeError::NotFound(format!("{expected} {id}")));
440                }
441                Some(AtomicDeleteKind::Note { .. }) => {}
442                Some(AtomicDeleteKind::Entity { .. }) => {
443                    return Err(RuntimeError::NotFound(format!("entity {id}")));
444                }
445                Some(AtomicDeleteKind::Edge) => {
446                    return Err(RuntimeError::NotFound(format!("edge {id}")));
447                }
448            }
449            if let Some(error) = runtime.stream_member_error(&note).await? {
450                return Err(error);
451            }
452            let namespace = note.namespace.clone();
453            // Storage parity: `note_soft_delete_statement`/
454            // `note_hard_delete_statement` are the SAME khive-db builders
455            // khive-db's own `SqlNoteStore::delete_note` calls.
456            let mut statements = if hard {
457                vec![
458                    PlanStatement {
459                        statement: delete_record_attachments_statement(
460                            id,
461                            AttachmentSubstrate::Note,
462                        ),
463                        guard: None,
464                    },
465                    PlanStatement {
466                        statement: note_hard_delete_statement(id),
467                        guard: Some(AffectedRowGuard::exactly(1)),
468                    },
469                ]
470            } else {
471                let deleted_at = chrono::Utc::now().timestamp_micros();
472                vec![PlanStatement {
473                    statement: note_soft_delete_statement(id, deleted_at),
474                    guard: Some(AffectedRowGuard::exactly(1)),
475                }]
476            };
477            if hard {
478                statements.extend(
479                    hard_delete_lineage_warning_statements(
480                        &namespace,
481                        &actor,
482                        id,
483                        SubstrateKind::Note,
484                    )
485                    .into_iter()
486                    .map(|statement| PlanStatement {
487                        statement,
488                        guard: None,
489                    }),
490                );
491                statements.push(PlanStatement {
492                    statement: purge_incident_edges_statement(id),
493                    guard: None,
494                });
495            }
496            // FTS + vector index purge, matching operations.rs
497            // `delete_note`: both soft and hard delete clean indexes (a hard
498            // delete of an already-tombstoned record must still purge
499            // them); only hard additionally cascades edges above.
500            push_index_purge_statements(
501                runtime,
502                &mut statements,
503                "fts_notes",
504                &namespace,
505                id,
506                "atomic-delete-note",
507            )
508            .await?;
509            // operations.rs's `delete_note` appends a `NoteDeleted` event
510            // after a successful row delete, on both soft and hard delete:
511            // same reasoning as the entity branch above.
512            statements.extend(event_append_statements(
513                token,
514                &namespace,
515                "delete",
516                EventKind::NoteDeleted,
517                SubstrateKind::Note,
518                id,
519                serde_json::json!({"id": id, "namespace": namespace, "hard": hard}),
520            )?);
521            Ok(AtomicOpPlan::Delete(DeletePlan {
522                target_id: id,
523                statements,
524                // A committed atomic note delete must fire the same
525                // pack-installed note-mutation hook `operations.rs::
526                // delete_note` fires, so a warm ANN cache sees the deletion
527                // even when the mutation went through the atomic-plan path.
528                post_commit: PostCommitEffect::NoteDeleted {
529                    note_id: id,
530                    kind: note.kind.clone(),
531                },
532            }))
533        }
534        Some(_) => Err(RuntimeError::InvalidInput(format!(
535            "delete target {id} must be an entity, note, or edge"
536        ))),
537        // `Resolved` has no `Edge` variant (same reasoning as
538        // `prepare_update`'s fallback above) — probe the graph store
539        // directly.
540        None => match &expected_kind {
541            Some(AtomicDeleteKind::Entity { .. }) => {
542                Err(RuntimeError::NotFound(format!("entity/note {id}")))
543            }
544            Some(AtomicDeleteKind::Note { .. }) => {
545                Err(RuntimeError::NotFound(format!("entity/note {id}")))
546            }
547            Some(AtomicDeleteKind::Edge) | None => {
548                let edge = if hard {
549                    runtime.get_edge_including_deleted(token, id).await?
550                } else {
551                    runtime.get_edge(token, id).await?
552                };
553                match edge {
554                    Some(edge) => prepare_delete_edge(token, id, edge, hard, &actor).await,
555                    None => Err(RuntimeError::NotFound(format!("entity/note/edge {id}"))),
556                }
557            }
558        },
559    }
560}
561
562/// Edge branch of `prepare_delete`. Mirrors
563/// `khive-runtime::operations::KhiveRuntime::delete_edge` exactly: hard
564/// delete cascades `purge_incident_edges` (any `annotates` edge — or any
565/// other edge — pointing AT this edge as a node) BEFORE deleting the edge
566/// row itself, then a soft or hard delete statement, then an unconditional
567/// `EdgeDeleted` event (edges are never FTS/vector-indexed, so unlike the
568/// entity/note branches there is no index purge here — `delete_edge` has
569/// none either).
570pub(crate) async fn prepare_delete_edge(
571    token: &NamespaceToken,
572    id: Uuid,
573    edge: khive_storage::types::Edge,
574    hard: bool,
575    actor: &str,
576) -> RuntimeResult<AtomicOpPlan> {
577    let namespace = edge.namespace.clone();
578    let mut statements: Vec<PlanStatement> = Vec::new();
579
580    if hard {
581        statements.extend(
582            hard_delete_lineage_warning_statements(&namespace, actor, id, SubstrateKind::Entity)
583                .into_iter()
584                .map(|statement| PlanStatement {
585                    statement,
586                    guard: None,
587                }),
588        );
589        // Mirrors `delete_edge`'s `graph.purge_incident_edges(edge_id)` —
590        // unguarded: zero incident edges is a legitimate outcome, not a
591        // failure (same reasoning as the entity/note cascade-edges
592        // statements above).
593        statements.push(PlanStatement {
594            statement: purge_incident_edges_statement(id),
595            guard: None,
596        });
597        statements.push(PlanStatement {
598            statement: edge_hard_delete_statement(id),
599            guard: Some(AffectedRowGuard::exactly(1)),
600        });
601    } else {
602        let now = chrono::Utc::now().timestamp_micros();
603        statements.push(PlanStatement {
604            statement: edge_soft_delete_statement(id, now),
605            guard: Some(AffectedRowGuard::exactly(1)),
606        });
607    }
608
609    statements.extend(event_append_statements(
610        token,
611        &namespace,
612        "delete",
613        EventKind::EdgeDeleted,
614        SubstrateKind::Entity,
615        id,
616        serde_json::json!({"id": id, "namespace": namespace, "hard": hard}),
617    )?);
618
619    Ok(AtomicOpPlan::Delete(DeletePlan {
620        target_id: id,
621        statements,
622        post_commit: PostCommitEffect::None,
623    }))
624}