Skip to main content

khive_runtime/curation/
entity_curation.rs

1#[cfg(test)]
2use super::race_seam;
3use super::{
4    entity_fts_document, map_merge_entity_storage_error, merge_entity_sql, merge_properties,
5    stale_entity_snapshot_error, Any, AtomicUnitOp, ContentMergeStrategy, EmbeddingModelPlan,
6    Entity, EntityDedupMergePolicy, EntityMergeValidation, EntityPatch, EventAttribution,
7    EventKind, HashMap, KhiveRuntime, MergeEventContext, MergeSqlError, MergeSummary,
8    MergeTxLimits, NamespaceToken, RuntimeError, RuntimeResult, RuntimeWriteOperation,
9    SqlStatement, SqlValue, SqliteError, SubstrateKind, Uuid, Value,
10};
11
12use crate::secret_gate_finalizer::entity_admission::{
13    EntityAdmission, OrdinaryEntityUpdateContext,
14};
15use crate::EntityCandidateMutation;
16
17#[derive(Clone, Copy)]
18enum EntityUpdateRoute<'a> {
19    ReservationOnly,
20    OrdinaryConstructor(&'a OrdinaryEntityUpdateContext),
21}
22
23struct PreparedEntityUpdate {
24    entity: Entity,
25    reindex_required: bool,
26    changed_fields: Vec<&'static str>,
27    expected_updated_at: i64,
28    expected_deleted_at: Option<i64>,
29    original: Entity,
30}
31
32impl KhiveRuntime {
33    pub(crate) async fn validate_canonical_entity_patch(
34        &self,
35        token: &NamespaceToken,
36        current: &Entity,
37        mut patch: EntityPatch,
38    ) -> RuntimeResult<EntityPatch> {
39        crate::atomic_prepare::canonical_refuse_registry_tags(&current.tags, "update")?;
40        if let Some(name) = &patch.name {
41            if name.trim().is_empty() {
42                return Err(RuntimeError::InvalidInput("name must not be empty".into()));
43            }
44            crate::secret_gate::check_at(name, "entity", "name")?;
45        }
46        if let Some(Some(description)) = &patch.description {
47            crate::secret_gate::check_at(description, "entity", "description")?;
48        }
49        if let Some(properties) = &patch.properties {
50            crate::secret_gate::check_json_at(properties, "entity", "properties")?;
51        }
52        if let Some(tags) = &patch.tags {
53            crate::atomic_prepare::canonical_refuse_registry_tags(tags, "update")?;
54            crate::secret_gate::check_tags_at(tags, "entity", "tags")?;
55        }
56        if let Some(Some(entity_type)) = &patch.entity_type {
57            patch.entity_type =
58                Some(self.validate_entity_type_for_kind(&current.kind, Some(entity_type))?);
59        }
60        // ADR-115 §4 against the prepared row: the writer repeats this against the row it
61        // meets, which also decides whether a stored reserved value carries.
62        if let Some(properties) = &patch.properties {
63            crate::secret_gate::strip_reserved_echo(
64                &mut properties.clone(),
65                current.properties.as_ref(),
66            )?;
67        }
68        let mut projected = current.clone();
69        Self::apply_entity_patch_fields(&mut projected, patch.clone(), &[], true);
70        if let Some(hook) = self.entity_kind_hook(&current.kind) {
71            hook.validate_entity_update(self, token, &projected, projected.properties.as_ref())
72                .await?;
73        }
74        Ok(patch)
75    }
76
77    pub(crate) fn apply_entity_patch_fields(
78        entity: &mut Entity,
79        patch: EntityPatch,
80        remove_properties: &[&str],
81        replace_properties: bool,
82    ) -> (bool, Vec<&'static str>) {
83        let mut reindex_required = false;
84        let mut changed_fields: Vec<&'static str> = Vec::new();
85
86        if let Some(name) = patch.name {
87            reindex_required |= entity.name != name;
88            entity.name = name;
89            changed_fields.push("name");
90        }
91        if let Some(desc_patch) = patch.description {
92            reindex_required |= entity.description != desc_patch;
93            entity.description = desc_patch;
94            changed_fields.push("description");
95        }
96        if let Some(props) = patch.properties {
97            if replace_properties {
98                entity.properties = Some(props);
99            } else {
100                let (merged, _) = merge_properties(
101                    &entity.properties,
102                    &Some(props),
103                    EntityDedupMergePolicy::PreferFrom,
104                );
105                entity.properties = merged;
106            }
107            changed_fields.push("properties");
108        }
109        if let Some(Value::Object(properties)) = entity.properties.as_mut() {
110            let mut removed = false;
111            for key in remove_properties {
112                removed |= properties.remove(*key).is_some();
113            }
114            if removed && !changed_fields.contains(&"properties") {
115                changed_fields.push("properties");
116            }
117        }
118        if let Some(tags) = patch.tags {
119            entity.tags = tags;
120            changed_fields.push("tags");
121        }
122        if let Some(entity_type) = patch.entity_type {
123            reindex_required |= entity.entity_type != entity_type;
124            entity.entity_type = entity_type;
125            changed_fields.push("entity_type");
126        }
127
128        (reindex_required, changed_fields)
129    }
130
131    /// Patch-style entity update.
132    ///
133    /// Only fields set to `Some(_)` are changed. Re-indexes FTS5 (and vectors if configured)
134    /// when `name`, `description`, or `entity_type` changes; skips re-indexing for
135    /// property/tag-only patches.
136    ///
137    /// Returns `RuntimeError::NotFound` if the entity does not exist or belongs to a different
138    /// namespace. Namespace isolation is enforced at the runtime layer.
139    /// Computes the patched `Entity`, `reindex_required`, and `changed_fields` without
140    /// writing anything, so both the normal write path and the atomic-prepare path
141    /// share one source of truth for what a patched entity looks like.
142    pub(crate) async fn prepare_update_entity(
143        &self,
144        token: &NamespaceToken,
145        id: Uuid,
146        patch: EntityPatch,
147    ) -> RuntimeResult<(Entity, bool, Vec<&'static str>, i64, Option<i64>)> {
148        self.prepare_guarded_entity_update(token, id, patch, None, &[])
149            .await
150    }
151
152    async fn prepare_guarded_entity_update(
153        &self,
154        token: &NamespaceToken,
155        id: Uuid,
156        patch: EntityPatch,
157        expected: Option<&Entity>,
158        remove_properties: &[&str],
159    ) -> RuntimeResult<(Entity, bool, Vec<&'static str>, i64, Option<i64>)> {
160        let prepared = self
161            .prepare_entity_update_for_route(
162                token,
163                id,
164                patch,
165                expected,
166                remove_properties,
167                EntityUpdateRoute::ReservationOnly,
168            )
169            .await?;
170        Ok((
171            prepared.entity,
172            prepared.reindex_required,
173            prepared.changed_fields,
174            prepared.expected_updated_at,
175            prepared.expected_deleted_at,
176        ))
177    }
178
179    async fn prepare_entity_update_for_route(
180        &self,
181        token: &NamespaceToken,
182        id: Uuid,
183        patch: EntityPatch,
184        expected: Option<&Entity>,
185        remove_properties: &[&str],
186        route: EntityUpdateRoute<'_>,
187    ) -> RuntimeResult<PreparedEntityUpdate> {
188        crate::secret_gate::reject_reserved_secret_gate_property(patch.properties.as_ref())?;
189        if !remove_properties.is_empty() {
190            let removals = Value::Object(
191                remove_properties
192                    .iter()
193                    .map(|key| ((*key).to_string(), Value::Null))
194                    .collect(),
195            );
196            crate::secret_gate::reject_reserved_secret_gate_property(Some(&removals))?;
197        }
198        if matches!(route, EntityUpdateRoute::ReservationOnly) {
199            if let Some(ref name) = patch.name {
200                crate::secret_gate::check_at(name, "entity", "name")?;
201            }
202            if let Some(Some(ref desc)) = patch.description {
203                crate::secret_gate::check_at(desc, "entity", "description")?;
204            }
205            if let Some(ref props) = patch.properties {
206                crate::secret_gate::check_json_at(props, "entity", "properties")?;
207            }
208            if let Some(ref tags) = patch.tags {
209                crate::secret_gate::check_tags_at(tags, "entity", "tags")?;
210            }
211        } else if let EntityUpdateRoute::OrdinaryConstructor(context) = route {
212            context.check_patch(
213                patch.name.as_deref(),
214                patch
215                    .description
216                    .as_ref()
217                    .and_then(|value| value.as_deref()),
218                patch.properties.as_ref(),
219                patch.tags.as_deref(),
220            )?;
221        }
222        let store = self.entities(token)?;
223        let mut entity = store.get_entity(id).await?.ok_or_else(|| {
224            if expected.is_some() {
225                stale_entity_snapshot_error(id)
226            } else {
227                RuntimeError::NotFound(format!("entity {id}"))
228            }
229        })?;
230        if let Some(expected) = expected {
231            let actual = serde_json::to_value(&entity)
232                .map_err(|error| RuntimeError::Internal(error.to_string()))?;
233            let expected = serde_json::to_value(expected)
234                .map_err(|error| RuntimeError::Internal(error.to_string()))?;
235            if actual != expected {
236                return Err(stale_entity_snapshot_error(id));
237            }
238        }
239        let original = entity.clone();
240        let expected_updated_at = entity.updated_at;
241        let expected_deleted_at = entity.deleted_at;
242        #[cfg(test)]
243        race_seam::pause_after_read().await;
244
245        // ADR-014 tri-state: outer `None` = unchanged; `Some(None)` = explicit
246        // clear (no vocabulary validation — there is no value to validate);
247        // `Some(Some(raw))` = set, validated and normalized.
248        let validated_entity_type = match &patch.entity_type {
249            Some(None) => Some(None),
250            Some(Some(raw)) => Some(Some(
251                self.validate_entity_type_for_kind(&entity.kind, Some(raw))?
252                    .expect("set branch always yields a normalized value"),
253            )),
254            None => None,
255        };
256
257        let patch = EntityPatch {
258            entity_type: validated_entity_type,
259            ..patch
260        };
261        let (reindex_required, changed_fields) =
262            Self::apply_entity_patch_fields(&mut entity, patch, remove_properties, false);
263
264        // A patch may carry properties from the stored row into the full
265        // replacement. Validate the final object, including that carry.
266        crate::secret_gate::reject_reserved_secret_gate_property(entity.properties.as_ref())?;
267
268        if expected.is_some() && changed_fields.is_empty() {
269            return Ok(PreparedEntityUpdate {
270                entity,
271                reindex_required,
272                changed_fields,
273                expected_updated_at,
274                expected_deleted_at,
275                original,
276            });
277        }
278
279        // #2943: `entity.properties`, `entity.entity_type`, and `entity.tags`
280        // are all final here — the owning pack's KindHook, if any, validates
281        // the resulting record, mirroring `prepare_note_update_hook` on the
282        // note side. `Ok(())` when no pack registered a hook for this entity
283        // kind (the trait default, or the runtime-layer aggregate was never
284        // installed). Placed after the no-op early return above so a
285        // genuinely unchanged guarded update never re-runs the hook for
286        // nothing; only `updated_at` remains to be bumped after this point.
287        if let Some(hook) = self.entity_kind_hook(&entity.kind) {
288            hook.validate_entity_update(self, token, &entity, entity.properties.as_ref())
289                .await?;
290        }
291
292        // `updated_at` is also the optimistic-concurrency revision for
293        // full-entity replacement. Make it strictly advance even when two
294        // operations land inside one clock microsecond. Saturation is not a
295        // valid fallback: reusing i64::MAX would make the CAS accept a write
296        // without advancing its revision.
297        let minimum_updated_at = expected_updated_at.checked_add(1).ok_or_else(|| {
298            RuntimeError::Internal(format!(
299                "entity {id} updated_at is already at i64::MAX and cannot advance"
300            ))
301        })?;
302        entity.updated_at = chrono::Utc::now()
303            .timestamp_micros()
304            .max(minimum_updated_at);
305        Ok(PreparedEntityUpdate {
306            entity,
307            reindex_required,
308            changed_fields,
309            expected_updated_at,
310            expected_deleted_at,
311            original,
312        })
313    }
314
315    #[cfg(test)]
316    pub(crate) async fn update_entity(
317        &self,
318        token: &NamespaceToken,
319        id: Uuid,
320        patch: EntityPatch,
321    ) -> RuntimeResult<Entity> {
322        Ok(self
323            .update_entity_with_embedding_report(token, id, patch)
324            .await?
325            .0)
326    }
327
328    pub async fn update_entity_with_embedding_report(
329        &self,
330        token: &NamespaceToken,
331        id: Uuid,
332        patch: EntityPatch,
333    ) -> RuntimeResult<(Entity, crate::retrieval::EmbeddingTruncationReport)> {
334        self.update_entity_with_expected_version_and_embedding_report(token, id, patch, None)
335            .await
336    }
337
338    /// Entity update with an optional caller revision, checked inside the writer transaction.
339    pub async fn update_entity_with_expected_version_and_embedding_report(
340        &self,
341        token: &NamespaceToken,
342        id: Uuid,
343        patch: EntityPatch,
344        expected_version: Option<i64>,
345    ) -> RuntimeResult<(Entity, crate::retrieval::EmbeddingTruncationReport)> {
346        crate::entity_write::validate_expected_version(expected_version)?;
347        let admission = OrdinaryEntityUpdateContext::capture(token);
348        let PreparedEntityUpdate {
349            entity,
350            reindex_required,
351            changed_fields,
352            expected_updated_at,
353            expected_deleted_at,
354            original,
355        } = self
356            .prepare_entity_update_for_route(
357                token,
358                id,
359                patch,
360                None,
361                &[],
362                EntityUpdateRoute::OrdinaryConstructor(&admission),
363            )
364            .await?;
365        let entity = match admission.admit(token, entity)? {
366            EntityAdmission::Legacy(entity) => entity,
367            EntityAdmission::Exempt(prepared) => {
368                use crate::atomic_plan::PostCommitEffect;
369                use crate::atomic_runner::{
370                    run_atomic_unit, AtomicOpFailure, AtomicOpPlan, AtomicRunOutcome,
371                };
372                let mut entity = prepared.entity().clone();
373                let next_version = original
374                    .version
375                    .checked_add(1)
376                    .ok_or_else(|| RuntimeError::InvalidInput("entity version overflow".into()))?;
377                let (mut required, report) = self
378                    .prepare_admitted_entity_indexes(token, &entity, &[], reindex_required)
379                    .await?;
380                required.extend(crate::atomic_prepare::event_append_statements(
381                    token, &entity.namespace, "update", EventKind::EntityUpdated,
382                    SubstrateKind::Entity, entity.id,
383                    serde_json::json!({"id": entity.id, "namespace": entity.namespace, "changed_fields": changed_fields}),
384                )?);
385                let plan = prepared.into_plan(
386                    EntityCandidateMutation::ReplaceIfUnchanged {
387                        expected: original,
388                        expected_version,
389                    },
390                    required,
391                    PostCommitEffect::None,
392                )?;
393                match run_atomic_unit(
394                    self.sql().as_ref(),
395                    vec![AtomicOpPlan::FinalizeEntity(Box::new(plan))],
396                )
397                .await
398                {
399                    Ok(AtomicRunOutcome::Committed { .. }) => {
400                        entity.version = next_version;
401                        return Ok((entity, report));
402                    }
403                    Ok(AtomicRunOutcome::RolledBack {
404                        failure: AtomicOpFailure::EntityConflict(conflict),
405                        ..
406                    }) => {
407                        return Err(conflict.into_error().into());
408                    }
409                    Ok(AtomicRunOutcome::RolledBack {
410                        failure: AtomicOpFailure::GuardFailed { .. },
411                        ..
412                    }) => {
413                        return Err(stale_entity_snapshot_error(id));
414                    }
415                    Ok(AtomicRunOutcome::RolledBack { failure, .. }) => {
416                        return Err(RuntimeError::Internal(format!(
417                            "entity update finalization rolled back: {failure:?}"
418                        )));
419                    }
420                    Err(error) => return Err(RuntimeError::Storage(error.0)),
421                }
422            }
423        };
424
425        self.persist_prepared_entity_update(
426            token,
427            entity,
428            reindex_required,
429            changed_fields,
430            expected_updated_at,
431            expected_deleted_at,
432            expected_version,
433        )
434        .await
435    }
436
437    /// Apply an admin patch only if the entity still matches the full read snapshot.
438    /// Property removals apply after the normal merge and preserve all other keys.
439    /// Missing keys alone are a no-op; reserved runtime-owned keys cannot be removed.
440    /// A changed, deleted, or missing entity returns a conflict without writing.
441    /// A bounded embedding returns a non-retryable error with the committed ID
442    /// and truncation report; use the report-aware variant to retain the record.
443    pub async fn update_entity_if_unchanged(
444        &self,
445        token: &NamespaceToken,
446        expected: &Entity,
447        patch: EntityPatch,
448        remove_properties: &[&str],
449    ) -> RuntimeResult<Entity> {
450        let (entity, embedding) = self
451            .update_entity_if_unchanged_with_embedding_report(
452                token,
453                expected,
454                patch,
455                remove_properties,
456            )
457            .await?;
458        crate::operations::legacy_post_commit_result_with_embedding(
459            "update_entity_if_unchanged",
460            entity.id,
461            entity,
462            embedding,
463            Vec::new(),
464        )
465    }
466
467    /// Apply a guarded admin patch and retain embedding truncation accounting.
468    pub async fn update_entity_if_unchanged_with_embedding_report(
469        &self,
470        token: &NamespaceToken,
471        expected: &Entity,
472        patch: EntityPatch,
473        remove_properties: &[&str],
474    ) -> RuntimeResult<(Entity, crate::retrieval::EmbeddingTruncationReport)> {
475        let (entity, reindex_required, changed_fields, expected_updated_at, expected_deleted_at) =
476            self.prepare_guarded_entity_update(
477                token,
478                expected.id,
479                patch,
480                Some(expected),
481                remove_properties,
482            )
483            .await?;
484        if changed_fields.is_empty() {
485            return Ok((
486                entity,
487                crate::retrieval::EmbeddingTruncationReport::default(),
488            ));
489        }
490        self.persist_prepared_entity_update(
491            token,
492            entity,
493            reindex_required,
494            changed_fields,
495            expected_updated_at,
496            expected_deleted_at,
497            None,
498        )
499        .await
500    }
501
502    #[allow(clippy::too_many_arguments)]
503    pub(super) async fn persist_prepared_entity_update(
504        &self,
505        token: &NamespaceToken,
506        mut entity: Entity,
507        reindex_required: bool,
508        changed_fields: Vec<&'static str>,
509        expected_updated_at: i64,
510        expected_deleted_at: Option<i64>,
511        expected_version: Option<i64>,
512    ) -> RuntimeResult<(Entity, crate::retrieval::EmbeddingTruncationReport)> {
513        // This final whole-object replacement must reserve the complete
514        // candidate, even if a future caller bypasses the patch preparer.
515        crate::secret_gate::reject_reserved_secret_gate_property(entity.properties.as_ref())?;
516        let id = entity.id;
517        let _ = self.entities(token)?;
518        let next_version = entity
519            .version
520            .checked_add(1)
521            .ok_or_else(|| RuntimeError::InvalidInput("entity version overflow".into()))?;
522        use crate::atomic_plan::{AffectedRowGuard, PlanStatement, PostCommitEffect, UpdatePlan};
523        use crate::atomic_runner::{
524            run_atomic_unit, AtomicOpFailure, AtomicOpPlan, AtomicRunOutcome,
525        };
526        let plan = UpdatePlan {
527            target_id: id,
528            statements: vec![PlanStatement {
529                statement: khive_db::stores::entity::entity_replace_if_unchanged_statement(
530                    &entity,
531                    expected_updated_at,
532                    expected_deleted_at,
533                ),
534                guard: Some(AffectedRowGuard::exactly(1)),
535            }],
536            post_commit: PostCommitEffect::None,
537            edge_natural_key: None,
538            idempotent_noop: false,
539            entity_guard: expected_version.map(|expected_version| {
540                crate::entity_write::EntityWriteGuard {
541                    id,
542                    expected_version,
543                }
544            }),
545            note_guard: None,
546            note_vector_purge: None,
547            note_embedding_inheritance: None,
548            graph_effects: Vec::new(),
549        };
550        match run_atomic_unit(
551            self.sql().as_ref(),
552            vec![AtomicOpPlan::Update(Box::new(plan))],
553        )
554        .await
555        {
556            Ok(AtomicRunOutcome::Committed { .. }) => entity.version = next_version,
557            Ok(AtomicRunOutcome::RolledBack {
558                failure: AtomicOpFailure::EntityConflict(conflict),
559                ..
560            }) => return Err(conflict.into_error().into()),
561            Ok(AtomicRunOutcome::RolledBack {
562                failure: AtomicOpFailure::GuardFailed { .. },
563                ..
564            }) => return Err(stale_entity_snapshot_error(id)),
565            Ok(AtomicRunOutcome::RolledBack { failure, .. }) => {
566                return Err(RuntimeError::Internal(format!(
567                    "entity update rolled back: {failure:?}"
568                )))
569            }
570            Err(error) => return Err(RuntimeError::Storage(error.0)),
571        }
572
573        let event_token =
574            token.with_namespace(crate::Namespace::parse(&entity.namespace).map_err(|error| {
575                RuntimeError::Internal(format!("entity namespace invalid: {error}"))
576            })?);
577        let event_store = self.events(&event_token)?;
578        let event = khive_storage::event::Event::new(
579            entity.namespace.clone(),
580            "update",
581            EventKind::EntityUpdated,
582            SubstrateKind::Entity,
583            "",
584        )
585        .with_target(entity.id)
586        .with_payload(serde_json::json!({
587            "id": entity.id,
588            "namespace": entity.namespace,
589            "changed_fields": changed_fields,
590        }));
591        let event_result = event_store.append_event(event).await.map_err(|e| {
592            RuntimeError::Internal(format!("update_entity: event store write failed: {e}"))
593        });
594
595        let embedding_report = if reindex_required {
596            self.reindex_entity(token, &entity).await?
597        } else {
598            crate::retrieval::EmbeddingTruncationReport::default()
599        };
600
601        event_result?;
602
603        Ok((entity, embedding_report))
604    }
605
606    /// Merge `from_id` into `into_id`.
607    ///
608    /// All edges incident to `from_id` are rewired to `into_id`. Self-loops that would
609    /// result from the rewire are dropped. Properties and tags are merged per `strategy`.
610    /// `from_id` is tombstoned with merge provenance and removed from indexes. Returns a summary.
611    ///
612    /// If `dry_run` is true, computes and returns the planned summary without mutating any rows.
613    ///
614    /// Atomic: all SQL (entity reads/writes, edge rewires, FTS updates, vec-index
615    /// delete, merge event with destructive edge preimages) runs on one pool
616    /// connection inside one `BEGIN IMMEDIATE` transaction via
617    /// `merge_entity_sql`. If embedding vectors are configured, the vector re-insert for
618    /// `into_id` is performed after the transaction (requires async embedding computation).
619    pub async fn merge_entity(
620        &self,
621        token: &NamespaceToken,
622        into_id: Uuid,
623        from_id: Uuid,
624        strategy: EntityDedupMergePolicy,
625        content_strategy: ContentMergeStrategy,
626        dry_run: bool,
627    ) -> RuntimeResult<MergeSummary> {
628        self.merge_entity_with_reason(
629            token,
630            into_id,
631            from_id,
632            strategy,
633            content_strategy,
634            dry_run,
635            None,
636        )
637        .await
638    }
639
640    /// Merge `from_id` into `into_id` and include an optional reason in the audit event.
641    // REASON: these arguments mirror the merge verb's policy, content strategy,
642    // dry-run, and audit-reason fields; a builder would only move that surface.
643    #[allow(clippy::too_many_arguments)]
644    pub async fn merge_entity_with_reason(
645        &self,
646        token: &NamespaceToken,
647        into_id: Uuid,
648        from_id: Uuid,
649        strategy: EntityDedupMergePolicy,
650        content_strategy: ContentMergeStrategy,
651        dry_run: bool,
652        reason: Option<String>,
653    ) -> RuntimeResult<MergeSummary> {
654        self.merge_entity_with_validation(
655            token,
656            into_id,
657            from_id,
658            strategy,
659            content_strategy,
660            dry_run,
661            reason,
662            EntityMergeValidation::LegacyKind,
663        )
664        .await
665    }
666
667    /// Merge two entities with an explicit override for the entity safety floor.
668    ///
669    /// Non-forced calls enforce entity kind, name similarity, and project compatibility
670    /// against the rows reread inside the merge transaction. Legacy merge methods retain
671    /// their historical same-kind-only policy.
672    /// A non-dry-run override is recorded as `force: true` in the merge event.
673    // REASON: these arguments mirror the merge verb's policy, content strategy,
674    // dry-run, audit-reason, and force fields; a builder would only move that surface.
675    #[allow(clippy::too_many_arguments)]
676    pub async fn merge_entity_with_reason_and_force(
677        &self,
678        token: &NamespaceToken,
679        into_id: Uuid,
680        from_id: Uuid,
681        strategy: EntityDedupMergePolicy,
682        content_strategy: ContentMergeStrategy,
683        dry_run: bool,
684        reason: Option<String>,
685        force: bool,
686    ) -> RuntimeResult<MergeSummary> {
687        let validation = if force {
688            EntityMergeValidation::Forced
689        } else {
690            EntityMergeValidation::SafetyFloor
691        };
692        self.merge_entity_with_validation(
693            token,
694            into_id,
695            from_id,
696            strategy,
697            content_strategy,
698            dry_run,
699            reason,
700            validation,
701        )
702        .await
703    }
704
705    #[allow(clippy::too_many_arguments)]
706    async fn merge_entity_with_validation(
707        &self,
708        token: &NamespaceToken,
709        into_id: Uuid,
710        from_id: Uuid,
711        strategy: EntityDedupMergePolicy,
712        content_strategy: ContentMergeStrategy,
713        dry_run: bool,
714        reason: Option<String>,
715        validation: EntityMergeValidation,
716    ) -> RuntimeResult<MergeSummary> {
717        if let Some(reason) = reason.as_deref() {
718            crate::secret_gate::check_at(reason, "merge", "reason")?;
719        }
720        if into_id == from_id {
721            return Err(RuntimeError::InvalidInput(
722                "cannot merge an entity into itself".into(),
723            ));
724        }
725        let ns = token.namespace().as_str().to_owned();
726        let fts_table = "fts_entities".to_string();
727        // One immutable registry view governs transactional deletion, table
728        // preparation, and survivor reindex. A late model belongs to a later
729        // write/backfill rather than only one leg of this merge.
730        let embedding_plan = EmbeddingModelPlan::capture(self);
731        let vec_tables = embedding_plan.vector_tables();
732        // Loaded once here (sync, cheap) so the rewire loop can evaluate the
733        // endpoint contract without an async round-trip per edge (khive#1216).
734        let pack_rules = self.pack_edge_rules();
735
736        // Ensure all required tables exist (idempotent DDL) before the transaction.
737        let _ = self.entities(token)?;
738        let _ = self.graph(token)?;
739        let _ = self.text(token)?;
740        let _ = self.events(token)?;
741        // vectors_for_model (not the default-model-only self.vectors()) so
742        // custom-only runtimes (no default embedding_model) still get DDL primed.
743        for model_name in embedding_plan.model_names() {
744            let _ = self.vectors_for_model(token, model_name)?;
745        }
746
747        let pool = self.backend().pool_arc();
748        let writer_task = pool
749            .writer_task_for_runtime_write(RuntimeWriteOperation::MergeEntity)
750            .map_err(RuntimeError::Storage)?;
751        // Minted before the transaction so the tombstone and its in-transaction
752        // EntityMerged event carry the same id.
753        let merge_event_id = Uuid::new_v4();
754        let event_context = MergeEventContext {
755            attribution: EventAttribution::from_token(token),
756            reason,
757            force: validation == EntityMergeValidation::Forced,
758            strategy,
759            content_strategy,
760            kind: EventKind::EntityMerged,
761            substrate: SubstrateKind::Entity,
762            event_id: Some(merge_event_id),
763        };
764
765        let (mut summary, updated_entity) = if let Some(writer_task) = writer_task {
766            writer_task
767                .send(move |conn| {
768                    merge_entity_sql(
769                        conn,
770                        ns,
771                        fts_table,
772                        vec_tables,
773                        into_id,
774                        from_id,
775                        strategy,
776                        content_strategy,
777                        dry_run,
778                        pack_rules,
779                        validation,
780                        MergeTxLimits::default(),
781                        merge_event_id,
782                        Some(event_context),
783                    )
784                    .map_err(|e| {
785                        e.into_storage_error(
786                            khive_storage::StorageCapability::Entities,
787                            "merge_entity",
788                        )
789                    })
790                })
791                .await
792                .inspect_err(|error| khive_storage::usage::account_event_write(Err(error)))
793                .map_err(map_merge_entity_storage_error)?
794        } else {
795            tokio::task::spawn_blocking(move || {
796                let guard = pool.writer()?;
797                let mut refusal = None;
798                let result = guard.transaction(|conn| {
799                    merge_entity_sql(
800                        conn,
801                        ns,
802                        fts_table,
803                        vec_tables,
804                        into_id,
805                        from_id,
806                        strategy,
807                        content_strategy,
808                        dry_run,
809                        pack_rules,
810                        validation,
811                        MergeTxLimits::default(),
812                        merge_event_id,
813                        Some(event_context),
814                    )
815                    .map_err(|error| match error {
816                        MergeSqlError::Sqlite(error) => error,
817                        MergeSqlError::Refusal(error) => {
818                            refusal = Some(error);
819                            SqliteError::InvalidData(
820                                "entity merge refused by transactional policy".to_string(),
821                            )
822                        }
823                    })
824                });
825                match refusal {
826                    Some(error) => Err(error),
827                    None => result.map_err(RuntimeError::from),
828                }
829            })
830            .await
831            .map_err(|e| RuntimeError::Internal(e.to_string()))??
832        };
833
834        // Count only committed event rows; dry-run never inserts an event.
835        if !dry_run {
836            khive_storage::usage::account_event_write(Ok(1));
837            tracing::info!(
838                into_id = %summary.kept_id,
839                from_id = %summary.removed_id,
840                budget_rows = summary.tx_budget.rows_charged,
841                budget_bytes = summary.tx_budget.bytes_charged,
842                budget_max_rows = summary.tx_budget.max_rows,
843                budget_max_bytes = summary.tx_budget.max_bytes,
844                "merge_entity: transaction materialization budget"
845            );
846        }
847
848        // FTS and vec-deletes already committed inside the transaction above;
849        // only the embedding re-insert needs an async step outside it.
850        if !dry_run && !embedding_plan.is_empty() {
851            match self
852                .reindex_entity_with_plan(token, &updated_entity, &embedding_plan, None)
853                .await
854            {
855                Ok(report) => summary.embedding_truncation = report,
856                Err(error) => {
857                    tracing::warn!(
858                        into_id = %summary.kept_id,
859                        from_id = %summary.removed_id,
860                        error = %error,
861                        "merge_entity: committed merge but survivor reindex failed"
862                    );
863                    summary.post_commit_reindex_error = Some(error.to_string());
864                }
865            }
866        }
867
868        Ok(summary)
869    }
870
871    // ---- Internal helpers ----
872
873    async fn apply_entity_index_revision(
874        &self,
875        entity: &Entity,
876        statements: Vec<SqlStatement>,
877    ) -> RuntimeResult<bool> {
878        let namespace = entity.namespace.clone();
879        let id = entity.id.to_string();
880        let version = entity.version;
881        let op: AtomicUnitOp = Box::new(move |writer| {
882            Box::pin(async move {
883                let current = writer
884                    .query_scalar(SqlStatement {
885                        sql: "SELECT version FROM entities \
886                              WHERE namespace=?1 AND id=?2 AND deleted_at IS NULL"
887                            .into(),
888                        params: vec![SqlValue::Text(namespace), SqlValue::Text(id)],
889                        label: Some("entity-index-revision".into()),
890                    })
891                    .await?;
892                if !matches!(current, Some(SqlValue::Integer(current)) if current == version) {
893                    return Ok(Box::new(false) as Box<dyn Any + Send>);
894                }
895                for statement in statements {
896                    writer.execute(statement).await?;
897                }
898                Ok(Box::new(true) as Box<dyn Any + Send>)
899            })
900        });
901        self.sql()
902            .atomic_unit(op)
903            .await?
904            .downcast::<bool>()
905            .map(|result| *result)
906            .map_err(|_| RuntimeError::Internal("invalid entity index outcome".into()))
907    }
908
909    pub(crate) fn entity_vector_insert_statements(
910        table: &str,
911        entity: &Entity,
912        model_name: &str,
913        vector: &[f32],
914    ) -> Vec<SqlStatement> {
915        let subject = entity.id.to_string();
916        let model_key = table
917            .strip_prefix("vec_")
918            .expect("runtime vector tables use the vec_ prefix");
919        let kind = SubstrateKind::Entity.to_string();
920        let field = "entity.body";
921        let blob = khive_storage::encode_f32_native(vector);
922        vec![
923            SqlStatement {
924                sql: format!(
925                    "INSERT INTO ann_write_log \
926                     (namespace, embedding_model, kind, field, subject_id, op) \
927                     SELECT namespace, embedding_model, kind, field, subject_id, 'delete' \
928                     FROM {table} WHERE subject_id=?1 AND NOT \
929                     (namespace=?2 AND embedding_model=?3 AND kind=?4 AND field=?5)"
930                ),
931                params: vec![
932                    SqlValue::Text(subject.clone()),
933                    SqlValue::Text(entity.namespace.clone()),
934                    SqlValue::Text(model_name.to_string()),
935                    SqlValue::Text(kind.clone()),
936                    SqlValue::Text(field.into()),
937                ],
938                label: Some("entity-reindex-log-delete".into()),
939            },
940            SqlStatement {
941                sql: format!("DELETE FROM {table} WHERE subject_id=?1"),
942                params: vec![SqlValue::Text(subject.clone())],
943                label: Some("entity-reindex-vector-delete".into()),
944            },
945            // This raw replacement cannot attest the embedded input. Clear the old
946            // sidecar in the same atomic index revision even when the new BLOB is
947            // byte-identical to the old one.
948            SqlStatement {
949                sql: "DELETE FROM vector_provenance \
950                      WHERE model_key = ?1 AND subject_id = ?2"
951                    .into(),
952                params: vec![
953                    SqlValue::Text(model_key.to_string()),
954                    SqlValue::Text(subject.clone()),
955                ],
956                label: Some("entity-reindex-provenance-clear".into()),
957            },
958            SqlStatement {
959                sql: format!(
960                    "INSERT INTO {table} \
961                     (subject_id, namespace, kind, field, embedding_model, embedding) \
962                     VALUES (?1, ?2, ?3, ?4, ?5, ?6)"
963                ),
964                params: vec![
965                    SqlValue::Text(subject.clone()),
966                    SqlValue::Text(entity.namespace.clone()),
967                    SqlValue::Text(kind.clone()),
968                    SqlValue::Text(field.into()),
969                    SqlValue::Text(model_name.to_string()),
970                    SqlValue::Blob(blob),
971                ],
972                label: Some("entity-reindex-vector-insert".into()),
973            },
974            SqlStatement {
975                sql: "INSERT INTO ann_write_log \
976                      (namespace, embedding_model, kind, field, subject_id, op) \
977                      VALUES (?1, ?2, ?3, ?4, ?5, 'upsert')"
978                    .into(),
979                params: vec![
980                    SqlValue::Text(entity.namespace.clone()),
981                    SqlValue::Text(model_name.to_string()),
982                    SqlValue::Text(kind),
983                    SqlValue::Text(field.into()),
984                    SqlValue::Text(subject),
985                ],
986                label: Some("entity-reindex-log-upsert".into()),
987            },
988        ]
989    }
990
991    pub(crate) async fn publish_entity_vector_revision(
992        &self,
993        token: &NamespaceToken,
994        entity: &Entity,
995        model_name: &str,
996        vector: &[f32],
997    ) -> RuntimeResult<bool> {
998        self.vectors_for_model(token, model_name)?;
999        let (storage_model, dimensions) = self.vector_model_metadata(model_name)?;
1000        if let Some(index) = crate::atomic_message::non_finite_index(vector) {
1001            return Err(RuntimeError::InvalidInput(format!(
1002                "non-finite entity vector at index {index}"
1003            )));
1004        }
1005        if vector.len() != dimensions {
1006            return Err(RuntimeError::InvalidInput(format!(
1007                "entity vector has {} dimensions; expected {dimensions}",
1008                vector.len()
1009            )));
1010        }
1011        let table = format!("vec_{}", crate::config::sanitize_key(&storage_model));
1012        let statements =
1013            Self::entity_vector_insert_statements(&table, entity, &storage_model, vector);
1014        #[cfg(test)]
1015        race_seam::pause_before_entity_vector_publish().await;
1016        self.apply_entity_index_revision(entity, statements).await
1017    }
1018
1019    /// Re-upsert FTS5 document and vector(s) for the entity across all registered models.
1020    ///
1021    /// Uses `entity.namespace` — the authoritative namespace stored on the record — rather
1022    /// than the caller-supplied `namespace` parameter. This prevents a cross-namespace
1023    /// reindex from writing the search document into the wrong namespace's FTS index.
1024    ///
1025    /// Best-effort for vectors: if embedding or inserting for a particular model fails,
1026    /// logs a warning and continues to the next model. The FTS step is fail-closed
1027    /// (propagates error). Callers (update_entity, merge_entity) have already committed
1028    /// the entity row, so a partial embed miss leaves a stale vector rather than
1029    /// rolling back the update. Each failed guarded vector replacement rolls back
1030    /// its own index writes, keeping the prior row searchable.
1031    pub(crate) async fn reindex_entity(
1032        &self,
1033        token: &NamespaceToken,
1034        entity: &Entity,
1035    ) -> RuntimeResult<crate::retrieval::EmbeddingTruncationReport> {
1036        let embedding_plan = EmbeddingModelPlan::capture(self);
1037        self.reindex_entity_with_plan(token, entity, &embedding_plan, None)
1038            .await
1039    }
1040
1041    pub(crate) async fn reindex_entity_with_precomputed(
1042        &self,
1043        token: &NamespaceToken,
1044        entity: &Entity,
1045        mut precomputed: HashMap<String, crate::retrieval::DocumentEmbeddingOutcome>,
1046    ) -> RuntimeResult<crate::retrieval::EmbeddingTruncationReport> {
1047        let embedding_plan = EmbeddingModelPlan::capture(self);
1048        self.reindex_entity_with_plan(token, entity, &embedding_plan, Some(&mut precomputed))
1049            .await
1050    }
1051
1052    pub(super) async fn reindex_entity_with_plan(
1053        &self,
1054        token: &NamespaceToken,
1055        entity: &Entity,
1056        embedding_plan: &EmbeddingModelPlan,
1057        mut precomputed: Option<&mut HashMap<String, crate::retrieval::DocumentEmbeddingOutcome>>,
1058    ) -> RuntimeResult<crate::retrieval::EmbeddingTruncationReport> {
1059        // Test-only fault seam: force the post-commit FTS leg to fail after a
1060        // merge or update has already persisted its entity row.
1061        #[cfg(test)]
1062        if crate::operations::consume_fts_fail_fault(&entity.namespace) {
1063            return Err(RuntimeError::Internal("injected FTS failure".to_string()));
1064        }
1065        // Use entity.namespace (authoritative) rather than token.namespace().as_str() (caller claim).
1066        let doc = entity_fts_document(entity);
1067        let embed_body = doc.body.clone();
1068        let _ = self.text(token)?;
1069        #[cfg(test)]
1070        race_seam::pause_before_entity_index_publish().await;
1071        let statements = khive_db::stores::text::delete_document_statements(
1072            "fts_entities",
1073            &entity.namespace,
1074            entity.id,
1075        )
1076        .into_iter()
1077        .chain(khive_db::stores::text::insert_document_statements(
1078            "fts_entities",
1079            &doc,
1080        ))
1081        .collect();
1082        if !self.apply_entity_index_revision(entity, statements).await? {
1083            return Ok(crate::retrieval::EmbeddingTruncationReport::default());
1084        }
1085
1086        let mut report = crate::retrieval::EmbeddingTruncationReport::default();
1087        for model_name in embedding_plan.model_names() {
1088            let embedding = match precomputed
1089                .as_mut()
1090                .and_then(|outcomes| outcomes.remove(model_name))
1091            {
1092                Some(outcome) => Ok(outcome),
1093                None => {
1094                    self.embed_document_with_model_outcome_for_token(token, model_name, &embed_body)
1095                        .await
1096                }
1097            };
1098            match embedding {
1099                Ok(outcome) => {
1100                    report.observe(&outcome);
1101                    match self
1102                        .publish_entity_vector_revision(token, entity, model_name, &outcome.vector)
1103                        .await
1104                    {
1105                        Ok(true) => {}
1106                        Ok(false) => break,
1107                        Err(error) => tracing::warn!(
1108                            model = model_name,
1109                            id = %entity.id,
1110                            "reindex_entity: vector insert failed, skipping model: {error}"
1111                        ),
1112                    }
1113                }
1114                Err(e) => {
1115                    tracing::warn!(
1116                        model = model_name,
1117                        id = %entity.id,
1118                        "reindex_entity: embed failed for model, skipping: {e}"
1119                    );
1120                }
1121            }
1122        }
1123
1124        Ok(report)
1125    }
1126}