Skip to main content

khive_runtime/operations/
entity_create.rs

1//! Entity construction, claims and their indexing outcomes.
2
3use super::*;
4use crate::secret_gate_finalizer::entity_admission::{
5    prepare_entity_admission, EntityAdmission, EntityEntryPoint,
6};
7use crate::EntityCandidateMutation;
8
9fn claimed_entity_create_event(entity: &Entity) -> Event {
10    let mut event = Event::new(
11        entity.namespace.clone(),
12        "create",
13        EventKind::EntityCreated,
14        SubstrateKind::Entity,
15        "",
16    )
17    .with_target(entity.id)
18    .with_payload(serde_json::json!({
19        "id": entity.id,
20        "namespace": &entity.namespace,
21        "kind": &entity.kind,
22    }));
23    let event_seed = Uuid::new_v5(&Uuid::NAMESPACE_URL, b"khive:claimed-entity-create:v1");
24    let mut event_key = Vec::with_capacity(24);
25    event_key.extend_from_slice(entity.id.as_bytes());
26    event_key.extend_from_slice(&entity.created_at.to_be_bytes());
27    event.id = Uuid::new_v5(&event_seed, &event_key);
28    event
29}
30
31impl KhiveRuntime {
32    /// Claim a caller-derived entity id without replacing a competing row.
33    /// Index writes are repeatable and never compensate by deleting the claim.
34    pub async fn claim_entity_if_absent(
35        &self,
36        token: &NamespaceToken,
37        spec: EntityClaimSpec,
38    ) -> RuntimeResult<(Entity, bool)> {
39        self.validate_entity_kind(&spec.kind)?;
40        let entity_type =
41            self.validate_entity_type_for_kind(&spec.kind, spec.entity_type.as_deref())?;
42        crate::secret_gate::reject_reserved_secret_gate_property(spec.properties.as_ref())?;
43
44        let mut proposed = Entity::new(token.namespace().as_str(), &spec.kind, &spec.name);
45        proposed.id = spec.id;
46        proposed.entity_type = entity_type.clone();
47        proposed.description = spec.description;
48        proposed.properties = spec.properties;
49        proposed.tags = spec.tags;
50
51        let admission = prepare_entity_admission(token, EntityEntryPoint::Claim, proposed)?;
52        let store = self.entities(token)?;
53        let (inserted, indexes_committed) = match admission {
54            EntityAdmission::Legacy(candidate) => {
55                proposed = candidate;
56                (
57                    store.insert_entity_if_absent(proposed.clone()).await?,
58                    false,
59                )
60            }
61            EntityAdmission::Exempt(prepared) => {
62                proposed = prepared.entity().clone();
63                let (mut required, _) = self
64                    .prepare_admitted_entity_indexes(token, &proposed, &[], true)
65                    .await?;
66                let _ = self.events(token)?;
67                let event = crate::EventAttribution::from_token(token)
68                    .stamp(claimed_entity_create_event(&proposed));
69                required.extend(
70                    khive_db::stores::event::event_insert_statements(&event)
71                        .map_err(|error| RuntimeError::Internal(error.to_string()))?
72                        .into_iter()
73                        .map(|statement| PlanStatement {
74                            statement,
75                            guard: Some(AffectedRowGuard::exactly(1)),
76                        }),
77                );
78                let plan = prepared.into_plan(
79                    EntityCandidateMutation::CreateIfAbsent,
80                    required,
81                    PostCommitEffect::None,
82                )?;
83                match run_atomic_unit(
84                    self.sql().as_ref(),
85                    vec![AtomicOpPlan::FinalizeEntity(Box::new(plan))],
86                )
87                .await
88                {
89                    Ok(AtomicRunOutcome::Committed { .. }) => (true, true),
90                    Ok(AtomicRunOutcome::RolledBack {
91                        failure: crate::atomic_runner::AtomicOpFailure::GuardFailed { .. },
92                        ..
93                    }) => (false, false),
94                    Ok(AtomicRunOutcome::RolledBack { failure, .. }) => {
95                        return Err(RuntimeError::Internal(format!(
96                            "entity claim finalization rolled back: {failure:?}"
97                        )));
98                    }
99                    Err(error) => return Err(RuntimeError::Storage(error.0)),
100                }
101            }
102        };
103        let entity = if inserted {
104            proposed
105        } else {
106            store
107                .get_entity_including_deleted(spec.id)
108                .await?
109                .ok_or_else(|| {
110                    RuntimeError::Internal(format!(
111                        "entity claim {} lost but the winning row is missing",
112                        spec.id
113                    ))
114                })?
115        };
116        if entity.deleted_at.is_some() {
117            return Err(RuntimeError::InvalidInput(format!(
118                "entity claim {} is soft-deleted; restore it explicitly",
119                entity.id
120            )));
121        }
122        if entity.namespace != token.namespace().as_str()
123            || entity.kind != spec.kind
124            || entity.entity_type.as_deref() != entity_type.as_deref()
125            || !entity.name.eq_ignore_ascii_case(&spec.name)
126            || !entity
127                .tags
128                .iter()
129                .any(|tag| tag.eq_ignore_ascii_case(&spec.identity_tag))
130        {
131            return Err(RuntimeError::InvalidInput(format!(
132                "entity claim {} belongs to a different record",
133                entity.id
134            )));
135        }
136
137        if !indexes_committed {
138            self.ensure_claimed_entity_create_event(token, &entity)
139                .await?;
140            self.reindex_claimed_entity(token, &entity).await?;
141        }
142        Ok((entity, inserted))
143    }
144
145    /// A claimed row may survive a failed event append. Verify the event before
146    /// a retry can report the row as registered.
147    pub async fn ensure_claimed_entity_create_event(
148        &self,
149        token: &NamespaceToken,
150        entity: &Entity,
151    ) -> RuntimeResult<()> {
152        if entity.namespace != token.namespace().as_str() || entity.deleted_at.is_some() {
153            return Err(RuntimeError::InvalidInput(format!(
154                "entity {} is not a live row in the write namespace",
155                entity.id
156            )));
157        }
158        let events = self.events(token).map_err(|error| {
159            RuntimeError::Internal(format!(
160                "entity {} persists but its create event store is unavailable: {error}",
161                entity.id
162            ))
163        })?;
164        let filter = EventFilter {
165            target_id: Some(entity.id),
166            kinds: vec![EventKind::EntityCreated],
167            verbs: vec!["create".into()],
168            substrates: vec![SubstrateKind::Entity],
169            after: Some(entity.created_at.saturating_sub(1)),
170            ..EventFilter::default()
171        };
172        let page = PageRequest {
173            offset: 0,
174            limit: 1,
175        };
176        if !events
177            .query_events(filter.clone(), page.clone())
178            .await?
179            .items
180            .is_empty()
181        {
182            return Ok(());
183        }
184
185        let event = claimed_entity_create_event(entity);
186        if let Err(error) = events.append_event(event).await {
187            if events.query_events(filter, page).await?.items.is_empty() {
188                return Err(RuntimeError::Internal(format!(
189                    "entity {} persists but its create event failed: {error}",
190                    entity.id
191                )));
192            }
193        }
194        Ok(())
195    }
196
197    /// Repair a claimed row after an earlier post-insert indexing failure.
198    /// This path is strict: any failed index stage names the still-live id.
199    pub async fn reindex_claimed_entity(
200        &self,
201        token: &NamespaceToken,
202        entity: &Entity,
203    ) -> RuntimeResult<()> {
204        if entity.namespace != token.namespace().as_str() || entity.deleted_at.is_some() {
205            return Err(RuntimeError::InvalidInput(format!(
206                "entity {} is not a live row in the write namespace",
207                entity.id
208            )));
209        }
210        let doc = entity_fts_document(entity);
211        let embed_body = doc.body.clone();
212        #[cfg(any(test, feature = "fault-injection"))]
213        let fts_inject = consume_fault(&FTS_FAIL_NS, &entity.namespace);
214        #[cfg(not(any(test, feature = "fault-injection")))]
215        let fts_inject = false;
216        let fts_result = if fts_inject {
217            Err(RuntimeError::Internal("injected FTS failure".into()))
218        } else {
219            match self.text(token) {
220                Ok(text) => text.upsert_document(doc).await.map_err(Into::into),
221                Err(error) => Err(error),
222            }
223        };
224        fts_result.map_err(|error| {
225            RuntimeError::Internal(format!(
226                "entity {} persists but its text index failed: {error}",
227                entity.id
228            ))
229        })?;
230
231        for model_name in self.registered_embedding_model_names() {
232            let outcome = self
233                .embed_document_with_model_outcome_for_token(token, &model_name, &embed_body)
234                .await
235                .map_err(|error| {
236                    RuntimeError::Internal(format!(
237                        "entity {} persists but model {model_name} embedding failed: {error}",
238                        entity.id
239                    ))
240                })?;
241            #[cfg(any(test, feature = "fault-injection"))]
242            let vector_inject = consume_fault(&VECTOR_FAIL_NS, &entity.namespace);
243            #[cfg(not(any(test, feature = "fault-injection")))]
244            let vector_inject = false;
245            if vector_inject {
246                return Err(RuntimeError::Internal(format!(
247                    "entity {} persists but model {model_name} vector indexing failed: injected vector failure",
248                    entity.id
249                )));
250            }
251            self.vectors_for_model(token, &model_name)
252                .map_err(|error| {
253                    RuntimeError::Internal(format!(
254                        "entity {} persists but model {model_name} vector store is unavailable: {error}",
255                        entity.id
256                    ))
257                })?
258                .insert(
259                    entity.id,
260                    SubstrateKind::Entity,
261                    &entity.namespace,
262                    "entity.body",
263                    vec![outcome.vector],
264                )
265                .await
266                .map_err(|error| {
267                    RuntimeError::Internal(format!(
268                        "entity {} persists but model {model_name} vector indexing failed: {error}",
269                        entity.id
270                    ))
271                })?;
272        }
273        Ok(())
274    }
275
276    /// Create and persist a new entity.
277    ///
278    /// Indexing failures trigger compensation across the entity row, FTS
279    /// document, and any vector models touched by this call. If compensation
280    /// also fails, the returned structured internal error identifies possible
281    /// partial persistence, includes both failure classes, and carries the
282    /// entity ID as a reconciliation handle.
283    // REASON: entity creation requires kind, type, name, description, properties, tags, and
284    // namespace token — refactoring into a builder would add indirection without reducing
285    // caller complexity; this signature mirrors the MCP verb surface directly.
286    #[allow(clippy::too_many_arguments)]
287    #[cfg(test)]
288    pub(crate) async fn create_entity(
289        &self,
290        token: &NamespaceToken,
291        kind: &str,
292        entity_type: Option<&str>,
293        name: &str,
294        description: Option<&str>,
295        properties: Option<serde_json::Value>,
296        tags: Vec<String>,
297    ) -> RuntimeResult<Entity> {
298        let (entity, _, degradations) = self
299            .create_entity_with_embedding_report_inner(
300                token,
301                kind,
302                entity_type,
303                name,
304                description,
305                properties,
306                tags,
307                Vec::new(),
308            )
309            .await?;
310        legacy_post_commit_result("create_entity", entity.id, entity, degradations)
311    }
312
313    /// Create an entity with role-keyed bytes already published to `BlobStore`.
314    ///
315    /// Every [`NewAttachment`] carries a typed content reference, so malformed
316    /// references cannot enter through this consumer seam. Blob existence is
317    /// checked before the database write. The entity row and all attachment rows
318    /// then commit in one storage transaction; the FTS/vector compensation path
319    /// hard-deletes the entity and its attachments together if a later indexing
320    /// step fails. Published bytes remain recoverable by the BlobStore grace-period
321    /// orphan policy when any post-publication step fails.
322    /// A bounded embedding returns a non-retryable error carrying the committed
323    /// entity ID and truncation report; use the report-aware variant to receive
324    /// the entity and report together.
325    #[allow(clippy::too_many_arguments)]
326    pub async fn create_entity_with_attachments(
327        &self,
328        token: &NamespaceToken,
329        kind: &str,
330        entity_type: Option<&str>,
331        name: &str,
332        description: Option<&str>,
333        properties: Option<serde_json::Value>,
334        tags: Vec<String>,
335        attachments: Vec<NewAttachment>,
336    ) -> RuntimeResult<Entity> {
337        let (entity, embedding, degradations) = self
338            .create_entity_with_attachments_inner(
339                token,
340                kind,
341                entity_type,
342                name,
343                description,
344                properties,
345                tags,
346                attachments,
347            )
348            .await?;
349        legacy_post_commit_result_with_embedding(
350            "create_entity_with_attachments",
351            entity.id,
352            entity,
353            embedding,
354            degradations,
355        )
356    }
357
358    /// Create an entity with attachments and retain embedding truncation accounting.
359    #[allow(clippy::too_many_arguments)]
360    pub async fn create_entity_with_attachments_and_report(
361        &self,
362        token: &NamespaceToken,
363        kind: &str,
364        entity_type: Option<&str>,
365        name: &str,
366        description: Option<&str>,
367        properties: Option<serde_json::Value>,
368        tags: Vec<String>,
369        attachments: Vec<NewAttachment>,
370    ) -> RuntimeResult<(Entity, crate::retrieval::EmbeddingTruncationReport)> {
371        let (entity, embedding, degradations) = self
372            .create_entity_with_attachments_inner(
373                token,
374                kind,
375                entity_type,
376                name,
377                description,
378                properties,
379                tags,
380                attachments,
381            )
382            .await?;
383        legacy_post_commit_result(
384            "create_entity_with_attachments_and_report",
385            entity.id,
386            (entity, embedding),
387            degradations,
388        )
389    }
390
391    #[allow(clippy::too_many_arguments)]
392    async fn create_entity_with_attachments_inner(
393        &self,
394        token: &NamespaceToken,
395        kind: &str,
396        entity_type: Option<&str>,
397        name: &str,
398        description: Option<&str>,
399        properties: Option<serde_json::Value>,
400        tags: Vec<String>,
401        attachments: Vec<NewAttachment>,
402    ) -> RuntimeResult<(
403        Entity,
404        crate::retrieval::EmbeddingTruncationReport,
405        Vec<PostCommitDegradation>,
406    )> {
407        // Attachment rows are the process-wide BlobStore's liveness authority.
408        // Validate placement before existence probes or any record write: pack
409        // runtimes bound to a secondary backend must explicitly call `core()`.
410        drop(self.attachments()?);
411        let blob_store = self.blob_store().ok_or_else(|| {
412            RuntimeError::Unconfigured(
413                "create_entity_with_attachments requires an installed BlobStore".to_string(),
414            )
415        })?;
416        let mut roles = std::collections::HashSet::with_capacity(attachments.len());
417        for attachment in &attachments {
418            attachment.validate()?;
419            if !roles.insert(attachment.role.as_str()) {
420                return Err(RuntimeError::InvalidInput(format!(
421                    "duplicate attachment role {:?}",
422                    attachment.role
423                )));
424            }
425        }
426        for attachment in &attachments {
427            if !blob_store.exists(&attachment.content_ref).await? {
428                return Err(RuntimeError::InvalidInput(format!(
429                    "create_entity_with_attachments requires a published blob; no object exists for {}",
430                    attachment.content_ref
431                )));
432            }
433        }
434        let validated_type = self.validate_entity_type_for_kind(kind, entity_type)?;
435        let (entity, embedding, degradations) = self
436            .create_entity_with_embedding_report_inner(
437                token,
438                kind,
439                validated_type.as_deref(),
440                name,
441                description,
442                properties,
443                tags,
444                attachments,
445            )
446            .await?;
447        Ok((entity, embedding, degradations))
448    }
449
450    #[allow(clippy::too_many_arguments)]
451    pub async fn create_entity_with_embedding_report(
452        &self,
453        token: &NamespaceToken,
454        kind: &str,
455        entity_type: Option<&str>,
456        name: &str,
457        description: Option<&str>,
458        properties: Option<serde_json::Value>,
459        tags: Vec<String>,
460    ) -> RuntimeResult<(Entity, crate::retrieval::EmbeddingTruncationReport)> {
461        let (entity, embedding, degradations) = self
462            .create_entity_with_embedding_report_inner(
463                token,
464                kind,
465                entity_type,
466                name,
467                description,
468                properties,
469                tags,
470                Vec::new(),
471            )
472            .await?;
473        legacy_post_commit_result(
474            "create_entity_with_embedding_report",
475            entity.id,
476            (entity, embedding),
477            degradations,
478        )
479    }
480
481    /// The committed entity and its non-retryable post-commit diagnostics.
482    #[allow(clippy::too_many_arguments)]
483    pub async fn create_entity_with_post_commit_report(
484        &self,
485        token: &NamespaceToken,
486        kind: &str,
487        entity_type: Option<&str>,
488        name: &str,
489        description: Option<&str>,
490        properties: Option<serde_json::Value>,
491        tags: Vec<String>,
492    ) -> RuntimeResult<(
493        Entity,
494        crate::retrieval::EmbeddingTruncationReport,
495        Vec<PostCommitDegradation>,
496    )> {
497        self.create_entity_with_embedding_report_inner(
498            token,
499            kind,
500            entity_type,
501            name,
502            description,
503            properties,
504            tags,
505            Vec::new(),
506        )
507        .await
508    }
509
510    #[allow(clippy::too_many_arguments)]
511    async fn create_entity_with_embedding_report_inner(
512        &self,
513        token: &NamespaceToken,
514        kind: &str,
515        entity_type: Option<&str>,
516        name: &str,
517        description: Option<&str>,
518        properties: Option<serde_json::Value>,
519        tags: Vec<String>,
520        attachments: Vec<NewAttachment>,
521    ) -> RuntimeResult<(
522        Entity,
523        crate::retrieval::EmbeddingTruncationReport,
524        Vec<PostCommitDegradation>,
525    )> {
526        self.validate_entity_kind(kind)?;
527        crate::secret_gate::reject_reserved_secret_gate_property(properties.as_ref())?;
528        let ns = token.namespace().as_str();
529        let mut entity = Entity::new(ns, kind, name).with_entity_type(entity_type);
530        if let Some(d) = description {
531            entity = entity.with_description(d);
532        }
533        if let Some(p) = properties {
534            entity = entity.with_properties(p);
535        }
536        if !tags.is_empty() {
537            entity = entity.with_tags(tags);
538        }
539        let projected_content_ref = attachments
540            .iter()
541            .find(|attachment| attachment.role == "content")
542            .map(|attachment| attachment.content_ref.to_string());
543        let attachment_rows: Vec<Attachment> = attachments
544            .into_iter()
545            .map(|attachment| {
546                Attachment::from_new(
547                    entity.id,
548                    AttachmentSubstrate::Entity,
549                    attachment,
550                    entity.created_at,
551                )
552            })
553            .collect();
554        entity.content_ref = projected_content_ref.clone();
555        entity = match prepare_entity_admission(token, EntityEntryPoint::Create, entity)? {
556            EntityAdmission::Legacy(candidate) => candidate,
557            EntityAdmission::Exempt(prepared) => {
558                let entity = prepared.entity().clone();
559                let (required, report) = self
560                    .prepare_admitted_entity_indexes(token, &entity, &attachment_rows, true)
561                    .await?;
562                let plan = prepared.into_plan(
563                    EntityCandidateMutation::CreateIfAbsent,
564                    required,
565                    PostCommitEffect::None,
566                )?;
567                match run_atomic_unit(
568                    self.sql().as_ref(),
569                    vec![AtomicOpPlan::FinalizeEntity(Box::new(plan))],
570                )
571                .await
572                {
573                    Ok(AtomicRunOutcome::Committed { .. }) => {
574                        let degradations = self.record_created_entity_event(token, &entity).await;
575                        return Ok((entity, report, degradations));
576                    }
577                    Ok(AtomicRunOutcome::RolledBack { failure, .. }) => {
578                        return Err(RuntimeError::Internal(format!(
579                            "entity finalization rolled back: {failure:?}"
580                        )));
581                    }
582                    Err(error) => return Err(RuntimeError::Storage(error.0)),
583                }
584            }
585        };
586        self.entities(token)?
587            .upsert_entity_with_attachments(entity.clone(), attachment_rows)
588            .await?;
589        entity.content_ref = projected_content_ref;
590
591        let doc = entity_fts_document(&entity);
592        let embed_body = doc.body.clone();
593
594        // FTS step — compensate entity row on failure (mirrors create_note_inner).
595        {
596            #[cfg(any(test, feature = "fault-injection"))]
597            let fts_inject = consume_fault(&FTS_FAIL_NS, ns);
598            #[cfg(not(any(test, feature = "fault-injection")))]
599            let fts_inject = false;
600            let fts_result: RuntimeResult<()> = if fts_inject {
601                Err(RuntimeError::Internal("injected FTS failure".to_string()))
602            } else {
603                match self.text(token) {
604                    Ok(fts) => fts.upsert_document(doc).await.map_err(RuntimeError::from),
605                    Err(e) => Err(e),
606                }
607            };
608            if let Err(e) = fts_result {
609                let cleanup_errors = self
610                    .compensate_entity_create(token, entity.id, ns, &[])
611                    .await;
612                return Err(Self::entity_create_failure(entity.id, e, cleanup_errors));
613            }
614        }
615
616        // Vector embedding + insert step — compensate entity row + FTS doc on failure.
617        // Fan out to ALL registered models (mirrors create_note_inner multi-model path).
618        let embed_model_names = {
619            let names = self.registered_embedding_model_names();
620            if names.is_empty() {
621                vec![]
622            } else {
623                names
624            }
625        };
626
627        let mut embedding_report = crate::retrieval::EmbeddingTruncationReport::default();
628        if embed_model_names.len() == 1 {
629            let model_name = &embed_model_names[0];
630            let vec_result = self
631                .embed_document_with_model_outcome_for_token(token, model_name, &embed_body)
632                .await;
633
634            #[cfg(any(test, feature = "fault-injection"))]
635            let vec_inject = consume_fault(&VECTOR_FAIL_NS, ns);
636            #[cfg(not(any(test, feature = "fault-injection")))]
637            let vec_inject = false;
638            let vec_result: RuntimeResult<crate::retrieval::DocumentEmbeddingOutcome> =
639                if vec_inject {
640                    Err(RuntimeError::Internal(
641                        "injected vector failure".to_string(),
642                    ))
643                } else {
644                    vec_result
645                };
646
647            let single_result: RuntimeResult<()> = match vec_result {
648                Ok(outcome) => {
649                    embedding_report.observe(&outcome);
650                    match self.vectors_for_model(token, model_name) {
651                        Ok(vs) => vs
652                            .insert(
653                                entity.id,
654                                SubstrateKind::Entity,
655                                ns,
656                                "entity.body",
657                                vec![outcome.vector],
658                            )
659                            .await
660                            .map_err(RuntimeError::from),
661                        Err(e) => Err(e),
662                    }
663                }
664                Err(e) => Err(e),
665            };
666            if let Err(e) = single_result {
667                let cleanup_errors = self
668                    .compensate_entity_create(
669                        token,
670                        entity.id,
671                        ns,
672                        std::slice::from_ref(model_name),
673                    )
674                    .await;
675                return Err(Self::entity_create_failure(entity.id, e, cleanup_errors));
676            }
677        } else if !embed_model_names.is_empty() {
678            // Multi-model path: embed with each model in parallel, then insert sequentially
679            // with inserted_models tracking for rollback on partial failure.
680            let rt_clone = self.clone();
681            let body_owned = embed_body.clone();
682            let usage_ctx = crate::usage::current();
683            let mut join_set = tokio::task::JoinSet::new();
684            for (idx, model_name) in embed_model_names.iter().enumerate() {
685                let rt = rt_clone.clone();
686                let text = body_owned.clone();
687                let name = model_name.clone();
688                let ctx = usage_ctx.clone();
689                let token = (*token).clone();
690                join_set.spawn(crate::runtime::inherit_request_embedder_scope(async move {
691                    let fut = rt.embed_document_with_model_outcome_for_token(&token, &name, &text);
692                    let result = match ctx {
693                        Some(ctx) => crate::usage::scope(ctx, fut).await,
694                        None => fut.await,
695                    };
696                    (idx, result)
697                }));
698            }
699            // The first failed or panicked handle aborts and detaches its
700            // siblings. Embed usage is counted at dispatch, so a synchronous
701            // provider winding down in the background cannot change it.
702            let outcomes = match drain_embed_join_set(join_set, embed_model_names.len()).await {
703                Ok(outcomes) => outcomes,
704                Err(e) => {
705                    let cleanup_errors = self
706                        .compensate_entity_create(token, entity.id, ns, &[])
707                        .await;
708                    return Err(Self::entity_create_failure(entity.id, e, cleanup_errors));
709                }
710            };
711            // TODO(P2): parallelize vector inserts
712            let mut inserted_models: Vec<String> = Vec::with_capacity(embed_model_names.len());
713            for (model_name, outcome) in embed_model_names.iter().zip(outcomes) {
714                embedding_report.observe(&outcome);
715                // Count-targetable fault injection for multi-model insert path.
716                #[cfg(any(test, feature = "fault-injection"))]
717                let count_inject = VECTOR_FAIL_AFTER.with(|cell| match cell.get() {
718                    Some(0) => {
719                        cell.set(None);
720                        true
721                    }
722                    Some(n) => {
723                        cell.set(Some(n - 1));
724                        false
725                    }
726                    None => false,
727                });
728                #[cfg(not(any(test, feature = "fault-injection")))]
729                let count_inject = false;
730
731                let insert_result = if count_inject {
732                    Err(RuntimeError::Internal(
733                        "injected vector insert failure".to_string(),
734                    ))
735                } else {
736                    match self.vectors_for_model(token, model_name) {
737                        Ok(vs) => vs
738                            .insert(
739                                entity.id,
740                                SubstrateKind::Entity,
741                                ns,
742                                "entity.body",
743                                vec![outcome.vector],
744                            )
745                            .await
746                            .map_err(RuntimeError::from),
747                        Err(e) => Err(e),
748                    }
749                };
750                if let Err(e) = insert_result {
751                    // Include the model whose INSERT returned an error: a backend
752                    // error does not prove the write had no side effects.
753                    let mut cleanup_models = inserted_models.clone();
754                    cleanup_models.push(model_name.clone());
755                    let cleanup_errors = self
756                        .compensate_entity_create(token, entity.id, ns, &cleanup_models)
757                        .await;
758                    return Err(Self::entity_create_failure(entity.id, e, cleanup_errors));
759                }
760                inserted_models.push(model_name.clone());
761            }
762        }
763
764        let degradations = self.record_created_entity_event(token, &entity).await;
765        Ok((entity, embedding_report, degradations))
766    }
767
768    async fn record_created_entity_event(
769        &self,
770        token: &NamespaceToken,
771        entity: &Entity,
772    ) -> Vec<PostCommitDegradation> {
773        // The arrival event, appended only after every compensating step has had
774        // its chance to fire: a create that rolled back returns above and never
775        // reaches here, so the event plane cannot name an entity that does not
776        // exist. Deletes and updates already emitted theirs; creates did not,
777        // which left the audit trail able to say what left the graph and not
778        // what entered it.
779        let created_event = khive_storage::event::Event::new(
780            entity.namespace.clone(),
781            "create",
782            EventKind::EntityCreated,
783            SubstrateKind::Entity,
784            "",
785        )
786        .with_target(entity.id)
787        .with_payload(serde_json::json!({
788            "id": entity.id,
789            "namespace": entity.namespace,
790            "kind": entity.kind,
791        }));
792        let event_result = match self.events(token) {
793            Ok(store) => store
794                .append_event(created_event)
795                .await
796                .map_err(RuntimeError::from),
797            Err(error) => Err(error),
798        };
799        let mut degradations = Vec::new();
800        if let Err(error) = event_result {
801            record_post_commit_degradation(
802                &mut degradations,
803                "create_entity",
804                entity.id,
805                "event_append",
806                error,
807            );
808        }
809
810        degradations
811    }
812}