1#[cfg(doc)]
2use super::AtomicDeleteKind;
3use super::{
4 entity_fts_document, entity_name_patch, entity_replace_if_unchanged_statement,
5 entity_upsert_statement, event_append_statements, insert_document_statements,
6 note_fts_document, note_upsert_statement, obj, optional_create_string,
7 optional_entity_type_patch, optional_f64_patch, optional_properties, optional_str,
8 optional_string_patch, optional_tags, prepare_delete, prepare_delete_edge, prepare_link,
9 prepare_merge, prepare_update_edge, prepare_update_entity_plan_with_version,
10 refuse_pack_registry_tags, require_str, require_uuid, AddEntityPlan, AddNotePlan,
11 AffectedRowGuard, AtomicOpPlan, EdgeUpsertDisposition, EventKind, KhiveRuntime, NamespaceToken,
12 PlanStatement, PostCommitEffect, Resolved, RuntimeError, RuntimeResult, SubstrateKind,
13 UpdatePlan, Uuid, Value,
14};
15
16pub async fn prepare_op(
26 runtime: &KhiveRuntime,
27 token: &NamespaceToken,
28 tool: &str,
29 args: &Value,
30) -> RuntimeResult<AtomicOpPlan> {
31 match tool {
32 "update" => prepare_update(runtime, token, args, None).await,
41 "delete" => prepare_delete(runtime, token, args, None).await,
50 "link" => prepare_link(runtime, token, args).await,
51 "merge" => prepare_merge(runtime, token, args).await,
52 "propose" | "review" | "withdraw" => prepare_governance_unimplemented(tool),
53 other => Err(RuntimeError::InvalidInput(format!(
54 "{other:?} has no atomic_prepare::prepare_op implementation; the CLI \
55 admissibility check should have rejected this before prepare"
56 ))),
57 }
58}
59
60fn prepare_governance_unimplemented(tool: &str) -> RuntimeResult<AtomicOpPlan> {
61 Err(RuntimeError::InvalidInput(format!(
62 "{tool:?} is on the ADR-099 v1 admissible verb list but has no --atomic \
63 prepare/apply implementation yet: its lifecycle (ADR-046) is an \
64 event-sourced changeset-interpreter over a dedicated `proposals_open` \
65 table, not a small guarded-DML plan — a faithful non-stub atomic \
66 prepare for it is tracked as ADR-099 follow-up work, not implemented \
67 in slice B3. No write was attempted."
68 )))
69}
70
71pub async fn prepare_add_entity(
81 runtime: &KhiveRuntime,
82 token: &NamespaceToken,
83 args: &Value,
84) -> RuntimeResult<AtomicOpPlan> {
85 prepare_add_entity_record(runtime, token, args, None)
86 .await
87 .map(|(_, plan)| plan)
88}
89
90pub(crate) async fn prepare_add_entity_record(
91 runtime: &KhiveRuntime,
92 token: &NamespaceToken,
93 args: &Value,
94 canonical_id: Option<Uuid>,
95) -> RuntimeResult<(khive_storage::Entity, AtomicOpPlan)> {
96 let kind = require_str(args, "kind")?;
97 let name = require_str(args, "name")?;
98 runtime.validate_entity_kind(kind)?;
99 if name.trim().is_empty() {
100 return Err(RuntimeError::InvalidInput(
101 "name must not be empty".to_string(),
102 ));
103 }
104
105 let description = optional_create_string(args, "description")?;
106 let properties = optional_properties(args, "properties")?;
107 let tags = optional_tags(args)?.unwrap_or_default();
108
109 crate::secret_gate::check_at(name, "entity", "name")?;
110 if let Some(ref d) = description {
111 crate::secret_gate::check_at(d, "entity", "description")?;
112 }
113 if let Some(ref p) = properties {
114 crate::secret_gate::check_json_at(p, "entity", "properties")?;
115 }
116 crate::secret_gate::check_tags_at(&tags, "entity", "tags")?;
117 crate::secret_gate::reject_reserved_secret_gate_property(properties.as_ref())?;
118
119 let ns = token.namespace().as_str();
120 let mut entity = khive_storage::Entity::new(ns, kind, name);
121 if let Some(id) = canonical_id {
122 entity.id = id;
123 entity.entity_type =
124 runtime.validate_entity_type_for_kind(kind, optional_str(args, "entity_type"))?;
125 }
126 if let Some(d) = description {
127 entity = entity.with_description(d);
128 }
129 if let Some(p) = properties {
130 entity = entity.with_properties(p);
131 }
132 if !tags.is_empty() {
133 entity = entity.with_tags(tags);
134 }
135
136 let mut statements = vec![PlanStatement {
137 statement: if canonical_id.is_some() {
138 khive_db::stores::entity::entity_insert_if_absent_statement(&entity)
139 } else {
140 entity_upsert_statement(&entity)
141 },
142 guard: Some(AffectedRowGuard::exactly(1)),
143 }];
144 for statement in insert_document_statements("fts_entities", &entity_fts_document(&entity)) {
148 statements.push(PlanStatement {
149 statement,
150 guard: None,
151 });
152 }
153
154 Ok((
155 entity.clone(),
156 AtomicOpPlan::AddEntity(AddEntityPlan {
157 entity_id: entity.id,
158 statements,
159 post_commit: PostCommitEffect::ReindexEntity {
160 entity_id: entity.id,
161 },
162 }),
163 ))
164}
165
166pub async fn prepare_add_note(
172 runtime: &KhiveRuntime,
173 token: &NamespaceToken,
174 args: &Value,
175) -> RuntimeResult<AtomicOpPlan> {
176 prepare_add_note_record(runtime, token, args, None)
177 .await
178 .map(|(_, plan)| plan)
179}
180
181pub(crate) async fn prepare_add_note_record(
182 runtime: &KhiveRuntime,
183 token: &NamespaceToken,
184 args: &Value,
185 canonical_id: Option<Uuid>,
186) -> RuntimeResult<(khive_storage::Note, AtomicOpPlan)> {
187 let kind = require_str(args, "kind")?;
188 let content = require_str(args, "content")?;
189 runtime.validate_note_kind(kind)?;
190
191 let name = optional_create_string(args, "name")?;
192 let properties = optional_properties(args, "properties")?;
193 let properties = runtime.derive_note_write_properties(kind, token, properties)?;
199
200 crate::secret_gate::check_note_fields_at(
201 content,
202 name.as_deref(),
203 properties.as_ref(),
204 "note",
205 )?;
206 crate::secret_gate::reject_reserved_secret_gate_property(properties.as_ref())?;
207
208 let ns = token.namespace().as_str();
209 let mut note = khive_storage::note::Note::new(ns, kind, content);
210 if let Some(id) = canonical_id {
211 note.id = id;
212 note.salience = optional_f64_patch(args, "salience")?.flatten();
213 note.decay_factor = optional_f64_patch(args, "decay_factor")?.flatten();
214 if note
215 .salience
216 .is_some_and(|value| !value.is_finite() || !(0.0..=1.0).contains(&value))
217 || note
218 .decay_factor
219 .is_some_and(|value| !value.is_finite() || value < 0.0)
220 {
221 return Err(RuntimeError::InvalidInput(
222 "invalid canonical note salience or decay_factor".into(),
223 ));
224 }
225 }
226 if let Some(n) = name {
227 note = note.with_name(n);
228 }
229 if let Some(p) = properties {
230 note = note.with_properties(p);
231 }
232
233 let mut statements = vec![PlanStatement {
234 statement: if canonical_id.is_some() {
235 khive_db::stores::note::note_insert_if_absent_statement(¬e)
236 } else {
237 note_upsert_statement(¬e)
238 },
239 guard: Some(AffectedRowGuard::exactly(1)),
240 }];
241 for statement in insert_document_statements("fts_notes", ¬e_fts_document(¬e)) {
244 statements.push(PlanStatement {
245 statement,
246 guard: None,
247 });
248 }
249
250 Ok((
251 note.clone(),
252 AtomicOpPlan::AddNote(Box::new(AddNotePlan {
253 note_guard: None,
254 note_id: note.id,
255 statements,
256 post_commit: PostCommitEffect::ReindexNote {
257 note_id: note.id,
258 version: note.version,
259 },
260 })),
261 ))
262}
263
264pub(super) fn reject_inapplicable_update_fields(
279 args: &Value,
280 substrate: &str,
281) -> RuntimeResult<()> {
282 let o = obj(args)?;
283 if substrate == "edge" && o.get("expected_version").is_some_and(|v| !v.is_null()) {
284 return Err(RuntimeError::InvalidInput(
285 "expected_version applies only to entities and notes".into(),
286 ));
287 }
288 if substrate != "note"
289 && ["embed", "fence"]
290 .iter()
291 .any(|field| o.contains_key(*field))
292 {
293 return Err(RuntimeError::InvalidInput(
294 "embed and fence apply only to notes".into(),
295 ));
296 }
297 let present = |k: &str| o.get(k).is_some_and(|v| !v.is_null());
298 let (bad_field, valid): (Option<&str>, &str) = match substrate {
299 "entity" => {
300 let bad = if present("content") {
301 Some("content")
302 } else if o.contains_key("salience") {
303 Some("salience")
304 } else if o.contains_key("decay_factor") {
305 Some("decay_factor")
306 } else if present("relation") {
307 Some("relation")
308 } else if present("weight") {
309 Some("weight")
310 } else {
311 None
312 };
313 (bad, "name, description, tags, properties, entity_type")
314 }
315 "note" => {
316 let bad = if o.contains_key("description") {
317 Some("description")
318 } else if present("relation") {
319 Some("relation")
320 } else if present("weight") {
321 Some("weight")
322 } else if o.contains_key("entity_type") {
323 Some("entity_type")
326 } else {
327 None
328 };
329 (
330 bad,
331 "name, content, salience, decay_factor, properties, tags",
332 )
333 }
334 "edge" => {
340 let bad = if present("name") {
341 Some("name")
342 } else if o.contains_key("description") {
343 Some("description")
344 } else if present("content") {
345 Some("content")
346 } else if present("tags") {
347 Some("tags")
348 } else if o.contains_key("salience") {
349 Some("salience")
350 } else if o.contains_key("decay_factor") {
351 Some("decay_factor")
352 } else if o.contains_key("entity_type") {
353 Some("entity_type")
356 } else {
357 None
358 };
359 (bad, "relation, weight, properties")
360 }
361 _ => (None, ""),
362 };
363 if let Some(field) = bad_field {
364 let substrate_label = match substrate {
365 "entity" => "an entity",
366 "note" => "a note",
367 "edge" => "an edge",
368 other => other,
369 };
370 return Err(RuntimeError::InvalidInput(format!(
371 "field '{field}' is not valid for {substrate_label}; valid fields: {valid}"
372 )));
373 }
374 Ok(())
375}
376
377pub enum AtomicUpdateKind {
387 Entity {
388 specific: Option<String>,
389 entity_type: Option<String>,
390 },
391 Note {
392 specific: Option<String>,
393 },
394 Edge,
395}
396
397pub fn validate_note_update_expected_kind(
402 note: &khive_storage::note::Note,
403 expected_kind: &Option<AtomicUpdateKind>,
404) -> RuntimeResult<()> {
405 let id = note.id;
406 match expected_kind {
407 None => Ok(()),
408 Some(AtomicUpdateKind::Note {
409 specific: Some(expected),
410 }) if ¬e.kind != expected => Err(RuntimeError::NotFound(format!("note {id}"))),
411 Some(AtomicUpdateKind::Note { .. }) => Ok(()),
412 Some(AtomicUpdateKind::Entity { .. }) => {
413 Err(RuntimeError::NotFound(format!("entity {id}")))
414 }
415 Some(AtomicUpdateKind::Edge) => Err(RuntimeError::NotFound(format!("edge {id}"))),
416 }
417}
418
419async fn prepare_note_update_plan_from_snapshot(
420 runtime: &KhiveRuntime,
421 token: &NamespaceToken,
422 args: &Value,
423 expected_kind: &Option<AtomicUpdateKind>,
424 note: khive_storage::note::Note,
425 policy: crate::NoteUpdatePolicy,
426 registry: Option<&crate::VerbRegistry>,
427) -> RuntimeResult<(khive_storage::Note, UpdatePlan)> {
428 let id = require_uuid(args, "id")?;
429 if note.id != id {
430 return Err(RuntimeError::NotFound(format!("note {id}")));
431 }
432 validate_note_update_expected_kind(¬e, expected_kind)?;
433
434 reject_inapplicable_update_fields(args, "note")?;
435 let mut normalized_args = args.clone();
436 crate::curation::normalize_note_update_tags(&mut normalized_args)?;
437 let args = &normalized_args;
438 let name = optional_string_patch(args, "name")?;
439 let content = optional_str(args, "content").map(str::to_string);
440 let properties = optional_properties(args, "properties")?;
441 let salience = optional_f64_patch(args, "salience")?;
442 let decay_factor = optional_f64_patch(args, "decay_factor")?;
443 let options = crate::note_write::NoteWriteOptions {
444 expected_version: obj(args)?
445 .get("expected_version")
446 .filter(|v| !v.is_null())
447 .map(|v| {
448 v.as_i64().ok_or_else(|| {
449 RuntimeError::InvalidInput("expected_version must be an integer".into())
450 })
451 })
452 .transpose()?,
453 fence: obj(args)?
454 .get("fence")
455 .map(|v| {
456 serde_json::from_value(v.clone())
457 .map_err(|error| RuntimeError::InvalidInput(format!("invalid fence: {error}")))
458 })
459 .transpose()?,
460 embed: obj(args)?
461 .get("embed")
462 .filter(|v| !v.is_null())
463 .map(|v| {
464 v.as_bool()
465 .ok_or_else(|| RuntimeError::InvalidInput("embed must be boolean".into()))
466 })
467 .transpose()?,
468 key: None,
469 };
470 let patch = crate::curation::NotePatch::new(name, content, salience, decay_factor, properties)
471 .with_update_policy(policy)
472 .with_write_options(options);
473 let (updated, mut plan) = runtime
474 .prepare_versioned_note_update(token, note.clone(), patch.clone())
475 .await?;
476 if let Some(registry) = registry {
477 attach_note_update_effects(runtime, token, registry, ¬e, &patch, &mut plan).await?;
478 }
479 Ok((updated, plan))
480}
481
482pub async fn prepare_update_from_note_snapshot(
492 runtime: &KhiveRuntime,
493 token: &NamespaceToken,
494 args: &Value,
495 expected_kind: Option<AtomicUpdateKind>,
496 note: khive_storage::note::Note,
497 policy: crate::NoteUpdatePolicy,
498 registry: &crate::VerbRegistry,
499) -> RuntimeResult<(khive_storage::Note, AtomicOpPlan)> {
500 if obj(args)?.get("entity_kind").is_some_and(|v| !v.is_null()) {
501 return Err(RuntimeError::InvalidInput(
502 "entity_kind is immutable; to change kind, delete then re-create the entity, \
503 or use merge() if this is a deduplication correction"
504 .into(),
505 ));
506 }
507 let (note, plan) = prepare_note_update_plan_from_snapshot(
508 runtime,
509 token,
510 args,
511 &expected_kind,
512 note,
513 policy,
514 Some(registry),
515 )
516 .await?;
517 Ok((note, AtomicOpPlan::Update(Box::new(plan))))
518}
519
520pub(crate) async fn attach_note_update_effects(
524 runtime: &KhiveRuntime,
525 token: &NamespaceToken,
526 registry: &crate::VerbRegistry,
527 snapshot: &khive_storage::Note,
528 patch: &crate::curation::NotePatch,
529 plan: &mut UpdatePlan,
530) -> RuntimeResult<()> {
531 use crate::atomic_plan::NoteUpdateStatement;
532 use crate::NoteUpdateEffect;
533
534 let Some(hook) = registry.find_kind_hook(&snapshot.kind) else {
535 return Ok(());
536 };
537 if let Some(expected) = patch.write_options.expected_version {
540 if expected != snapshot.version {
541 return Err(crate::note_write::NoteWriteConflict::Version {
542 expected,
543 current: snapshot.version,
544 }
545 .into_error()
546 .into());
547 }
548 }
549 let current = runtime.notes(token)?.get_note(snapshot.id).await?;
550 if !current.is_some_and(|current| {
551 current.updated_at == snapshot.updated_at
552 && current.deleted_at == snapshot.deleted_at
553 && current.version == snapshot.version
554 }) {
555 return Err(crate::curation::stale_note_snapshot_error(snapshot.id));
556 }
557 let effects = hook
558 .note_update_effects(runtime, token, snapshot, patch)
559 .await?;
560 if plan.idempotent_noop && !effects.is_empty() {
561 return Err(RuntimeError::InvalidInput(
562 "an unchanged note update cannot carry graph effects".into(),
563 ));
564 }
565 let edge_token = token.with_namespace(
566 crate::Namespace::parse(&snapshot.namespace)
567 .map_err(|error| RuntimeError::Internal(format!("invalid note namespace: {error}")))?,
568 );
569 for effect in effects {
570 match effect {
571 NoteUpdateEffect::Link(spec) => {
572 if spec.source_id != snapshot.id
573 || spec
574 .namespace
575 .as_deref()
576 .is_some_and(|ns| ns != snapshot.namespace)
577 {
578 return Err(RuntimeError::InvalidInput(
579 "note update links must originate from the note in its namespace".into(),
580 ));
581 }
582 let mut args = serde_json::json!({
583 "source_id": spec.source_id, "target_id": spec.target_id,
584 "relation": spec.relation, "weight": spec.weight,
585 "resurrect": spec.resurrect,
586 });
587 if let Some(metadata) = spec.metadata {
588 args["metadata"] = metadata;
589 }
590 let AtomicOpPlan::Link(link) = prepare_link(runtime, &edge_token, &args).await?
591 else {
592 return Err(RuntimeError::Internal("expected a link plan".into()));
593 };
594 if link.disposition == EdgeUpsertDisposition::Updated {
598 return Err(khive_types::KhiveError::conflict(
599 "a live edge appeared while preparing the note update; retry with fresh state",
600 ).into());
601 }
602 plan.graph_effects
603 .extend(link.statements.into_iter().map(NoteUpdateStatement::Write));
604 }
605 NoteUpdateEffect::DeleteEdge(edge) => {
606 let id = Uuid::from(edge.id);
607 if edge.source_id != snapshot.id
608 || edge.namespace != snapshot.namespace
609 || edge.deleted_at.is_some()
610 {
611 return Err(RuntimeError::InvalidInput(
612 "note update deletes must select an outgoing edge in the note namespace"
613 .into(),
614 ));
615 }
616 plan.graph_effects
617 .push(NoteUpdateStatement::Assert(PlanStatement {
618 statement: khive_db::stores::graph::edge_snapshot_assertion_statement(
619 &edge, false,
620 ),
621 guard: Some(AffectedRowGuard::exactly(1)),
622 }));
623 let actor = format!("{}:{}", token.actor().kind, token.actor().id);
624 let AtomicOpPlan::Delete(delete) =
625 prepare_delete_edge(&edge_token, id, edge, false, &actor).await?
626 else {
627 return Err(RuntimeError::Internal(
628 "expected an edge delete plan".into(),
629 ));
630 };
631 if delete.post_commit != PostCommitEffect::None {
632 return Err(RuntimeError::Internal(
633 "edge delete has a deferred effect".into(),
634 ));
635 }
636 plan.graph_effects.extend(
637 delete
638 .statements
639 .into_iter()
640 .map(NoteUpdateStatement::Write),
641 );
642 }
643 NoteUpdateEffect::AssertLink(edge) => {
644 if edge.source_id != snapshot.id
645 || edge.namespace != snapshot.namespace
646 || edge.deleted_at.is_some()
647 {
648 return Err(RuntimeError::InvalidInput(
649 "note update assertions must select a live outgoing edge in the note namespace".into(),
650 ));
651 }
652 plan.graph_effects
653 .push(NoteUpdateStatement::Assert(PlanStatement {
654 statement: khive_db::stores::graph::edge_snapshot_assertion_statement(
655 &edge, true,
656 ),
657 guard: Some(AffectedRowGuard::exactly(1)),
658 }));
659 }
660 }
661 }
662 Ok(())
663}
664
665pub async fn prepare_update(
673 runtime: &KhiveRuntime,
674 token: &NamespaceToken,
675 args: &Value,
676 expected_kind: Option<AtomicUpdateKind>,
677) -> RuntimeResult<AtomicOpPlan> {
678 let id = require_uuid(args, "id")?;
679
680 if obj(args)?.get("entity_kind").is_some_and(|v| !v.is_null()) {
684 return Err(RuntimeError::InvalidInput(
685 "entity_kind is immutable; to change kind, delete then re-create the entity, \
686 or use merge() if this is a deduplication correction"
687 .into(),
688 ));
689 }
690
691 match runtime.resolve_by_id(token, id).await? {
692 Some(Resolved::Entity(entity)) => {
693 match &expected_kind {
694 None => {}
695 Some(AtomicUpdateKind::Entity {
696 specific: Some(expected),
697 ..
698 }) if &entity.kind != expected => {
699 return Err(RuntimeError::NotFound(format!("entity {id}")));
700 }
701 Some(AtomicUpdateKind::Entity { .. }) => {}
702 Some(AtomicUpdateKind::Note { .. }) => {
703 return Err(RuntimeError::NotFound(format!("note {id}")));
704 }
705 Some(AtomicUpdateKind::Edge) => {
706 return Err(RuntimeError::NotFound(format!("edge {id}")));
707 }
708 }
709 if let Some(AtomicUpdateKind::Entity {
710 entity_type: Some(expected),
711 ..
712 }) = &expected_kind
713 {
714 if entity
715 .entity_type
716 .as_deref()
717 .is_some_and(|actual| actual != expected.as_str())
718 {
719 return Err(RuntimeError::NotFound(format!("entity {id}")));
720 }
721 }
722 refuse_pack_registry_tags(&entity.tags, "update")?;
723 reject_inapplicable_update_fields(args, "entity")?;
731 let name = entity_name_patch(args)?;
732 let description = optional_entity_type_patch(args, "description")?;
733 let properties = optional_properties(args, "properties")?;
734 let tags = optional_tags(args)?;
735 if let Some(ref tags) = tags {
736 refuse_pack_registry_tags(tags, "update")?;
737 }
738 let entity_type = optional_entity_type_patch(args, "entity_type")?;
739
740 let expected_version = obj(args)?
741 .get("expected_version")
742 .filter(|v| !v.is_null())
743 .map(|value| {
744 value.as_i64().ok_or_else(|| {
745 RuntimeError::InvalidInput("expected_version must be an integer".into())
746 })
747 })
748 .transpose()?;
749 let required_entity_type = match &expected_kind {
750 Some(AtomicUpdateKind::Entity { entity_type, .. }) => entity_type.as_deref(),
751 _ => None,
752 };
753 prepare_update_entity_plan_with_version_and_type(
754 runtime,
755 token,
756 id,
757 crate::curation::EntityPatch {
758 name,
759 description,
760 properties,
761 tags,
762 entity_type,
763 },
764 expected_version,
765 required_entity_type,
766 )
767 .await
768 }
769 Some(Resolved::Note(note)) => {
770 let (_, plan) = prepare_note_update_plan_from_snapshot(
775 runtime,
776 token,
777 args,
778 &expected_kind,
779 note,
780 crate::NoteUpdatePolicy::default(),
781 None,
782 )
783 .await?;
784 Ok(AtomicOpPlan::Update(Box::new(plan)))
785 }
786 Some(_) => Err(RuntimeError::InvalidInput(format!(
787 "update target {id} must be an entity, note, or edge"
788 ))),
789 None => match &expected_kind {
796 Some(AtomicUpdateKind::Entity { .. }) => {
797 Err(RuntimeError::NotFound(format!("entity/note {id}")))
798 }
799 Some(AtomicUpdateKind::Note { .. }) => {
800 Err(RuntimeError::NotFound(format!("entity/note {id}")))
801 }
802 Some(AtomicUpdateKind::Edge) | None => match runtime.get_edge(token, id).await? {
803 Some(edge) => prepare_update_edge(runtime, token, id, edge, args).await,
804 None => Err(RuntimeError::NotFound(format!("entity/note/edge {id}"))),
805 },
806 },
807 }
808}
809
810pub async fn prepare_update_entity_plan(
814 runtime: &KhiveRuntime,
815 token: &NamespaceToken,
816 id: Uuid,
817 patch: crate::curation::EntityPatch,
818) -> RuntimeResult<AtomicOpPlan> {
819 prepare_update_entity_plan_with_version(runtime, token, id, patch, None).await
820}
821
822pub(super) async fn prepare_update_entity_plan_with_version_and_type(
823 runtime: &KhiveRuntime,
824 token: &NamespaceToken,
825 id: Uuid,
826 patch: crate::curation::EntityPatch,
827 expected_version: Option<i64>,
828 required_entity_type: Option<&str>,
829) -> RuntimeResult<AtomicOpPlan> {
830 crate::entity_write::validate_expected_version(expected_version)?;
831 let explicit_entity_type_patch = patch.entity_type.is_some();
832 let (entity, reindex_required, changed_fields, expected_updated_at, expected_deleted_at) =
833 runtime.prepare_update_entity(token, id, patch).await?;
834 if required_entity_type.is_some_and(|expected| {
835 (explicit_entity_type_patch || entity.entity_type.is_some())
836 && entity.entity_type.as_deref() != Some(expected)
837 }) {
838 return Err(RuntimeError::InvalidInput(
839 "kind subtype contradicts the requested entity_type update".into(),
840 ));
841 }
842 let mut statements = vec![PlanStatement {
843 statement: entity_replace_if_unchanged_statement(
844 &entity,
845 expected_updated_at,
846 expected_deleted_at,
847 ),
848 guard: Some(AffectedRowGuard::exactly(1)),
849 }];
850 statements.extend(event_append_statements(
851 token,
852 &entity.namespace,
853 "update",
854 EventKind::EntityUpdated,
855 SubstrateKind::Entity,
856 id,
857 serde_json::json!({
858 "id": id,
859 "namespace": entity.namespace,
860 "changed_fields": changed_fields,
861 }),
862 )?);
863 let post_commit = if reindex_required {
864 PostCommitEffect::ReindexEntity { entity_id: id }
865 } else {
866 PostCommitEffect::None
867 };
868 Ok(AtomicOpPlan::Update(Box::new(UpdatePlan {
869 graph_effects: Vec::new(),
870 note_vector_purge: None,
871 note_embedding_inheritance: None,
872 entity_guard: expected_version.map(|expected_version| {
873 crate::entity_write::EntityWriteGuard {
874 id,
875 expected_version,
876 }
877 }),
878 note_guard: None,
879 target_id: id,
880 statements,
881 post_commit,
882 edge_natural_key: None,
883 idempotent_noop: false,
884 })))
885}