1use 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 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 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 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 #[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 #[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 #[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 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 #[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 {
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 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 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 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 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 #[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 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 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}